← Table of Contents
Chapter 11

Developer Reference

APIs for agent authors, player implementors, and 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.

Design principle

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:

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"] }

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.

Security: path traversal

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.

KeyDefault pathWhat it points to
"Instructions"instructions.txtCharacter definition file
"System Prompt"system-prompt.txtSystem prompt template
"Description"description.txtShort character description
"Introduction"intro.txtOpening 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.

Bundling built-in agents

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 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
 */
FieldRequiredDescription
@agentYesUnique agent ID. Use a namespace prefix that identifies you: mysite.battle-manager. The studio. prefix is reserved for platform built-ins.
@versionNoSemver string. Informational.
@descriptionNoShown in player UIs and store panels.
@tool0+Declares a tool the model can call. Each @tool block spans one @tool, one @tool-description, and one @tool-params.
@tool-descriptionNoOne sentence describing when the model should call this tool. Sent directly to the model in the tool definition.
@tool-paramsNoJSON Schema object describing the tool's parameters.
@command0+Declares a direct command and the exported function that handles it. Format: @command @name exportedFn.
@preloadNotrue to call onLoad at conversation start. Default: false.
@api0+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.
@lib0+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.
@hook0+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.
@debugNotrue 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;
}
EventWhen it firesevent payload
conversationStartThe 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)
beforeResponseAfter 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)
afterResponseAfter 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)
afterToolCallAfter 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
afterUserEditWhen 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.

Hooks are best-effort

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.

Command handlers are optional

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.

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 {
    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.

Download weighted-choice.js

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()}`);
}

Download sample-agent.js

---

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:

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:

  1. Reads config.json and builds a path resolver from fileMappings (see the Character Package section above). All subsequent file reads use this resolver — never hard-coded default paths.
  2. Reads the character content files through the resolver: instructions, system prompt, description, intro, and all files in the context directory.
  3. Parses the "agents" array and, for each entry, loads the agent file from the package's agents/ directory — .js, .py, or .lua, resolved in that order (see Agent languages in a package) — or, for sub-agent entries, the .subagent.md prompt file. See Composed agent entries for object entries.
  4. Parses each agent's manifest comment block to extract tools, commands, flags, declared @api namespaces, and hooks. Sub-agents get their tool definition from their frontmatter instead.
  5. 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.
  6. 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 calls onLoad. Concatenates all non-null return values and sends the result as a user message with isFile: 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:

  1. Stream the response. If finish_reason is "stop", the loop ends — deliver the final response to the consumer.
  2. If finish_reason is "tool_calls":
    1. Append the assistant's tool-call message to the conversation: { role: "assistant", tool_calls: [...] }
    2. 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.
    3. 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.
    4. Append a tool result message to the conversation: { role: "tool", tool_call_id: "...", content: "result string" }
  3. 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:

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:

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:

MethodRequiredBehavior
studio.toast(message)YesShow a transient notification. Headless players may implement this as a no-op.
studio.log(message)YesRecord a persistent audit entry in the conversation. Must survive reloads. Must not be sent to the model.
studio.history.search(query)YesCase-insensitive substring search over user/assistant turns. Returns [{ role, content, index }] where index is zero-based.
studio.history.range(start, end)YesReturns 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)YesReturns 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-agentsOne 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-agentsReturns 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 — optionalFetches 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.

ConditionError message must contain
Host does not provide web access"not implemented" — the canonical stub response
Agent did not declare @api studio.webEnforced 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 typeHost-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:

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:

  1. Registration. For each sub-agent entry, read the prompt file from agents/, parse and validate its frontmatter, apply any per-instance overrides from the config.json entry, and register an instance of studio.sub-agent whose tool definition comes from the frontmatter. Pass { meta, promptBody } as context.config.
  2. Grants. A sub-agent entry can list shared-agents (references to existing top-level instances) and private-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.
  3. 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 @api enforcement), append the results, and call again — up to the frontmatter's maxRounds (default 5, maximum 10). Every round is metered, and every granted call gets a toolCallId. 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:

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.

Note

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:

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:

EventPayloadWhen
message{ role, content }Final assistant response delivered
tokenstringStreaming token (if streaming is enabled)
agentLogstringAn agent called studio.log()
agentToaststringAn agent called studio.toast()
toolCall{ agentId, toolName, result }An agent tool call completed
errorErrorAPI 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.