The Agent System
Give your character reliable memory, state, and tools that survive context resets.
- What Agents Are
- Enabling Agents
- The Agent Command Dialog
- The Agent Store Panel
- The Agent Log
- Default Agent Data
- Built-in Agents
- Composable Data Lookup Agents
- Custom Agent Files
- Python Agents
- Lua Agents
- Library Files
- The Unverified Agent Warning
- Choosing the Right Agents
- Writing Agent-Aware System Prompts
Of all the features in Character Studio, agents are the one that most visibly separates a basic Venice character from a sophisticated one. A character without agents relies entirely on the conversation history to "remember" things — who the user is, what happened last session, what stats are in play, where the story stands. That works fine for short conversations, but history has a hard limit. It gets truncated. It gets expensive. And even within a single context window, models attend unevenly to things buried fifty turns back. Agents solve this problem at its root: they persist state to files in your project, inject that state as structured context at the start of every conversation, and give the model callable tools to update that state in real time. The conversation can be short because the important facts are already loaded before the first message is sent.
What Agents Are
Each agent is a JavaScript module that does three things. First, it declares one or more tools — functions the model can call mid-conversation using the standard Venice tool-use protocol. These tools read and write to files in your project. Second, it injects a context block at the start of every conversation: a short, structured summary of current state that the model sees before any messages. Third, it maintains its own storage file inside your project directory, so state persists across page reloads, browser restarts, and context resets. When you load a conversation, the agent's current state is already there. When the model updates a value during a conversation, that update is written to disk immediately. Nothing lives only in the context window.
This architecture has a deliberate consequence: agent state is independent of conversation length. A character can run for a hundred sessions, spanning thousands of turns, with the context cleared between each one — and as long as agents are enabled, the character's accumulated knowledge (who the user is, what happened, what the stats are, where the story stands) is available at the start of every new conversation. That's what makes ongoing story experiences possible. That's what makes a character feel like they actually know you over time.
Agents that declare @preload true or @context inject a context block at the start of every conversation — a few lines describing current state or a fixed behavioral instruction. Multiple such agents add multiple blocks. These blocks use tokens at the start of every message, so there is a cost to enabling many agents. Choose only the agents that genuinely improve your character — a simple conversational assistant needs none of them.
Enabling Agents
Agents are enabled per-project in config.json. Open the Config tab and add an "agents" array containing the IDs of the agents you want to activate:
{
"agents": ["studio.memory-store", "studio.character-stats"]
}
Press Ctrl+S to save the config. The agents activate immediately — their context blocks will appear from the next conversation onward. You don't need to restart anything or reload the page.
To disable an agent, remove its ID from the array and save. Its stored data remains on disk — nothing is deleted — it simply won't be loaded or injected anymore. If you re-enable the agent later, it picks up exactly where it left off. This makes disabling agents safe to experiment with: you can toggle them without losing any accumulated state.
If you're adding agents to an existing project for the first time, it helps to start a new conversation after enabling them so the context block loads cleanly. Conversations that were already in progress before agents were enabled won't have been shaped by those agents' context, which can cause mild inconsistencies in the early messages of a long session.
The Agent Command Dialog
Most agent interactions happen through the model calling tools autonomously — you write a message, the model decides to update a stat or log an event, and the update happens invisibly behind the scenes. But sometimes you need to set or correct values directly, without making an API call. That's what the Agent Command Dialog is for.
Press @ (or your configured trigger key) in an empty chat input to open the dialog. You'll see a textarea where you can type one or more agent commands, one per line. Commands are direct instructions to the agent system — they're executed immediately without routing through the model. This means no tokens consumed, no generation latency, and no risk of the model interpreting the command differently than you intended.
Examples of valid commands:
memory set character_name Ariamemory set user_name Marcusstats set HP 20stats delta gold -15event storm approaching from the north 1 hour 35 minutesevent Pizza delivery 35msearch betrayal
Press Ctrl+Enter to execute all commands. Each line is processed in order. If a command fails — because of an unrecognized agent ID, a malformed argument, or a non-existent key — the dialog retains the unexecuted lines and shows an error message. You can correct the problematic line and try again. If you dismiss the dialog without executing, your draft is saved so it's still there next time you open it.
The command dialog is a Studio-only authoring tool: the players keep the command machinery internally but expose no UI for it, so don't design characters that depend on users typing commands.
You can change the trigger key from @ to any other character in Settings → Configure Agent Command Key. This is useful if your character's universe involves @ symbols in dialogue and you don't want to accidentally open the dialog mid-message.
Most agent interactions happen automatically — once the event agent is active, the model registers and advances events on its own. The command dialog exists for testing and manual correction, not routine use. The search command is the same: running it manually shows you what the history search would return, but doesn't inform the model. Use it to verify that searches surface what you expect before you write system prompt instructions that ask the model to search.
The Agent Store Panel
The Agents button in the chat toolbar opens the Agent Store Panel — a full-view interface for inspecting and directly editing everything your active agents have stored. Think of it as a live view into your character's persistent state.
The panel lists all active agents with a count of how many items they currently hold. Click any agent to expand it and see its data. Each agent has a custom editor tailored to its data type: memory items show key-value pairs; character stats show a table of stat names and current values with delta history; the quest log shows active quests and history; the NPC tracker shows character records with disposition and location. These aren't raw JSON editors — they're purpose-built interfaces that make reading and editing natural.
To edit an item, click the pencil icon next to it. An inline editor opens in place — a key field, a value field, or a form appropriate to the data type. Press Ctrl+Enter to save the edit; press Escape to cancel without changes. The Add button at the bottom of each agent section adds a new item directly.
Deletions in the panel are soft: a deleted item shows with strikethrough styling and a Restore button. The deletion isn't committed to disk until you close the panel. This gives you a chance to review everything you've changed and reverse any mistakes before they're written. Click Close or press Escape to apply all pending changes and return to the conversation.
When an agent has data in it, a Set default button appears in its row header. Clicking it saves the agent's current store as the starting state for all future conversations in this project. See Default Agent Data below for details.
Note: User-written agents will use a generic editor that displays the stored information as plain text. There is currently no plan to allow user-written agents to define a custom editing interface.
The Agent Log
The Agent Log button in the chat toolbar opens a timestamped record of every tool call and direct command the active agents processed — including what arguments were passed, what result was returned, and how long each call took in milliseconds. It also surfaces the Context Injection block: the exact string your agent's onLoad returned, as it was injected into the model's context at conversation start.
The log is only written when an agent declares @debug true in its manifest. The button only appears in the toolbar when at least one debug-enabled agent is active. This keeps the log intentional — it's a development tool, not something that runs for every character by default.
Each log entry shows:
- Time — ISO timestamp of the call
- Path — whether the call came from the model (🤖) or was a direct user command (👤)
- Name — the tool name or command token
- Args — the arguments passed to the agent
- Result — the string the agent returned
- ms — how long the call took
Sub-agents (Chapter 8) write richer entries to the same log: a per-invocation cost summary (callModel), one entry per inner-loop model round (round), and one per granted-tool execution (grantedTool).
The log shows the 100 most recent entries, newest first. It is stored in a sidecar file alongside the conversation (conversation_01.agentid.agent-log.json) and is excluded from git and from shareable exports — it is a developer artifact, not part of the character definition.
To enable the log for one of the built-in agents, add @debug true to a copy of its manifest — or more practically, use it when authoring your own custom agents (see Custom Agent Files below) to verify that your agent is being called with the arguments you expect and returning the results you intend.
Default Agent Data
By default, every new conversation starts with an empty agent store — the character has no memories, no stats, no tracked NPCs. That's appropriate for many characters, but for others you want to ship with a pre-configured starting state: a memory-store pre-loaded with the character's background, an NPC tracker already populated with key characters, or starting stats set for a particular game system.
The Set default button in the Agent Store Panel makes this easy. Once an agent has data in it, the button appears in the agent's row header. Clicking it writes the agent's current store to a file named {agentId}.agent.json in your project root — for example, studio.memory-store.agent.json. Any new conversation created after that point will start with that data already loaded. Existing conversations are never affected.
The typical authoring flow is:
- Start a conversation and populate the agent's data however you like — use the command dialog, let the model call tools during a Playtest session, or edit items directly in the Agent Store Panel.
- Once the store looks right, open the Agent Store Panel and click Set default in that agent's header.
- Create a new conversation to verify that the default state loads correctly.
Default files use the .agent.json extension and are tracked in git, unlike conversation sidecars (which are gitignored). If you distribute a character to others, include the default files — they ensure recipients start from the intended state rather than a blank slate.
Built-in Agents
Character Studio ships with a set of built-in agents covering the most common persistence needs across character types. They're designed to compose well — most serious characters use two to four of them together. Each is described in detail below.
studio.memory-store
studio.memory-store is the foundational agent and the one most characters benefit from. It stores arbitrary named facts as key-value pairs that the model can read and write at any time. Important decisions made in earlier sessions. The character's current emotional stance toward the user. Long-term relationship milestones. Anything factual that the character should "know" but that would otherwise get buried in or cut from the conversation history.
The model can call set_memory(key, value) to store a fact and get_memory(key) to retrieve one. You can also set facts directly using the command dialog: memory set user_name Marcus. Setting a key that already exists overwrites the previous value — this is how you correct something the model stored incorrectly. The preloaded context block shows all stored memories at the start of every conversation, so the model begins each session already aware of what it knows. There's no need for the user to reintroduce themselves.
The most effective use of memory-store comes from your system prompt instructions. Tell the model explicitly what to remember and when: "When the user tells you their name, save it immediately with set_memory('user_name', ...). When the user expresses a strong preference — a favorite food, a fear, an ambition — save it. Recall the user's name at the start of the conversation if it's stored." The agent does nothing on its own; it provides the tools, and your instructions tell the model how to use them.
Note: The Omnius System Prompt has a [MEMORY] command which asks the model to store a memory (which lives in the history whether this agent is active or not). You can easily create a command like this in your own system prompt, if needed.
studio.character-stats
studio.character-stats tracks numerical values: hit points, gold, experience, relationship scores, skill levels, resource counts, or any quantity your scenario needs. The key feature beyond simple storage is delta operations — the model can call delta_stat('HP', -3) to reduce HP by 3 without needing to know the current value first. This matters in practice because models are unreliable at arithmetic in context: they'll confidently state that 20 minus 3 is 16. Offloading the arithmetic to the agent produces accurate, auditable tracking.
Every stat change is logged with a timestamp and reason string, creating a full audit trail. You can see the history of any stat in the Agent Store Panel. This makes it easy to spot when the model incorrectly applied damage or when a gold cost was wrong, and to correct it with a direct delta command.
Activate this agent any time you're building a character with game mechanics. Set initial values either through the command dialog (stats set HP 20, stats set gold 50) or by editing them directly in the Agent Store Panel. Your instructions should tell the model the rules: when to apply damage, what things cost, what stat thresholds trigger events.
studio.event-tracker
studio.event-tracker owns story time — as a ledger, not a single number. Every time the model reports that in-world time passed, the advance is recorded in a time log with its note: advance_time(120, "pool, shower, sandwiches") becomes a permanent, searchable entry. The current in-world date is computed by replaying that log from the story's start date — we set it in the Agent Store Panel, or the clock pins itself to the present the first time it runs. Time-limited events live on the same timeline as absolute fire instants, so their remaining time is derived from the ledger; you schedule one with a duration and description ("35m", "1h", "5 hours 30 minutes" are all accepted), and toast notifications appear at the ten-minute and five-minute marks and when it fires. One timeline drives everything, so the date and the timers can never disagree.
Story time is narrative: real wall-clock time never advances it. Two hours can pass at the pool during ten real minutes of chat, or a week of real time can pass with the story frozen mid-scene. For large jumps ("the next morning", "three days later") the model calls set_date with a concrete date, which is recorded as a keyframe — an absolute date that hard-resets the running clock. Events count down through a forward skip and can fire inside it; a backwards keyframe simply rewinds the clock (nothing un-fires). Because keyframes are absolute, they absorb any edits made to log entries before them — the story explicitly declared that date, and it stays declared. Worlds with their own calendars still work: a stylized date like "14th of Harvest Moon, Year 1247" becomes a label in the log, the date display switches to manual mode, and countdowns keep running on the underlying minute axis. The current story date — weekday included, computed for the story's year — appears in the preloaded context block above the event list; the log itself never reaches the model's prompt.
The tracker also keeps the model's estimates honest. The tool guidance is calibrated — an ordinary exchange is 1–3 minutes, fractions are allowed (an intense thirty-second kiss is 0.5, not 240), and dramatic moments are explicitly not long moments — and any single advance of four hours or more is rejected once, with a message asking the model to either re-call with confirm: true if the story really skipped that long or send a realistic estimate instead. We found this necessary in playtesting: models otherwise convert narrative importance into elapsed hours.
Instead, the model queries the log on demand with find_event: asked "When did we crash the car?" it searches the recorded notes and labels mechanically (word overlap — cheap, instant, no model call) and answers with the story timestamp: "crashed the car on Main Street" — Sunday, December 3, 1905, 9:30 AM (2 hours ago). The same tool searches forward: "When is the party?" returns the time remaining and the fire date. In manual mode the answers are relative — "about 3 hours ago."
This is the right tool for any scenario with time pressure: a potion wearing off, a guard shift changing, a tow truck arriving, a pizza being delivered, reinforcements arriving, a boat departing from the harbor in an hour. Without a time tracker, the model will lose track of these deadlines as the conversation grows. This is especially important for events far into the future (hours or days) as the context will surely run out before the event occurs.
Commands: event the poison begins to take hold 35m to schedule an event; events to list all active events and their IDs; remove-event <id> to cancel one before it fires; date to show the current story date, its anchor, and elapsed minutes; add-offset 90m (or 2h, 1 hour 30 minutes) to push story time forward ourselves without a model call — the date moves and countdowns tick, exactly as if the model had advanced it. Event IDs are generated automatically from the event name — spaces and punctuation become underscores, so event Pizza delivery 35m gets the ID pizza_delivery. Scheduling a new event with the same name overwrites the existing one, which is useful for resetting a timer. Use events to look up the exact ID before removing.
In the Agent Store Panel, the tracker's editor shows the whole timeline. A "Story start" field with a date-and-time picker (a complete decision, no partial dates, defaulting to 9:00 AM) anchors the clock; changing it offers to clear the log or keep it and replay from the new start. Below it, the Time Log lists every record — ⏩ advances with their minutes and note, 📌 keyframes with their date, 🏷 labels — each showing the computed story date after it. Every record is editable and deletable, and edits ripple: add an extra hour to an afternoon three entries back and every date downstream, plus every event countdown, recomputes on the spot. Keyframes fence your edits — changes above a keyframe are absorbed by it. Clicking Set default asks where new conversations should begin — back at the story start, or at the current story moment — and either way ships an empty log with event timers converted back to relative minutes.
studio.quest-log
studio.quest-log tracks active goals with defined completion criteria. The model can call add_quest(title, description) to log a new quest when the user accepts a task, complete_quest(id) when a goal is achieved, and fail_quest(id) when it becomes impossible. Completed and failed quests move from the Active list to the History section, where they remain as a record of the journey.
The preloaded context block shows all active quests and recent completions. This keeps the model aware of what goals are in play without you needing to remind it every few turns. In longer story arcs, where a user might accumulate half a dozen concurrent goals across multiple sessions, the quest log prevents the common failure mode where the model simply forgets about outstanding objectives.
Quest-log pairs well with memory-store (for story decisions) and event-tracker (for time-sensitive quest deadlines). Together, those three agents cover most of what a persistent story character needs.
studio.todo-list
studio.todo-list tracks a lightweight task list with priorities and statuses. The model can call add_todo(id, content, priority) to add an item, update_todo(id, status) to mark it pending, in_progress, completed, or cancelled, and remove_todo(id) to delete an item entirely. Priority levels are high, medium, and low.
Only open items (pending or in_progress) are injected into the model's context at conversation load — completed and cancelled items are retained in storage but not shown, keeping the context block short regardless of how many tasks have accumulated. The agent prunes old closed items automatically once they exceed a cap, so the store doesn't grow without bound.
Unlike quest-log, which tracks narrative goals with a pass/fail outcome, todo-list is designed for practical task tracking — shopping lists, character errands, mission objectives, things a character needs to do rather than story arcs to complete. Use todos in the command dialog to see all open items and the five most recently closed. IDs work the same way as the event agent: auto-generated from the name, and a duplicate ID returns an error rather than overwriting, so each task stays distinct. For characters that are helping a user create something (images, prompts, speeches) this will allow it to make a todo list for either the model to complete or the user.
studio.calendar (deprecated)
studio.calendar has been retired and its functionality folded into studio.event-tracker. In practice the two agents competed for story time: the model reliably reported elapsed minutes to the event tracker while the calendar's date sat still (or drifted on its own), and a date that disagrees with the timers is worse than no date at all. The event tracker now owns both — one clock drives the story date and the countdowns together. If a character config still lists studio.calendar, remove it and add studio.event-tracker instead; the date display, manual fantasy-calendar mode, and the Story start editor field all live there now.
studio.npc-tracker
studio.npc-tracker maintains records for named non-player characters: their current location, disposition toward the user, and a freeform notes field that can capture relationship history, known secrets, debts owed, and anything else worth tracking. The model can update records as relationships evolve — warming a contact's disposition after a favor, updating a location when an NPC moves cities, adding a note when an important secret is revealed.
For characters who operate in a populated world where the user will encounter the same people across many sessions, npc-tracker is indispensable. Without it, the model must infer NPC dispositions from whatever context remains in the window, and it will frequently get things wrong — treating a hard-won ally as a stranger, or forgetting that a particular merchant now owes the user a debt. With npc-tracker, the preloaded context block surfaces the current state of every tracked NPC so the model knows exactly where each relationship stands before a word is spoken.
Use the command npcs to list all tracked characters. Add new ones directly in the Agent Store Panel or let the model add them automatically when it first encounters someone significant.
studio.location
studio.location tracks where the user currently is in your world: the current location name, a description, any notes specific to that location, and a movement history showing where the user has been. The model calls move_to(location, description) when the user travels.
The preloaded context block includes the current location name and description, so every response is grounded in the correct setting. This is subtle but powerful: without it, the model frequently loses track of where the scene is set in long conversations, defaulting to vague environmental descriptions or outright forgetting that the user entered a specific room several turns ago. With location tracking, the model always knows where it is.
Use location in the command dialog to see the current location and recent movement history. This agent is best suited to exploration-heavy characters: dungeon masters, travel guides, wilderness scouts, anything where the physical setting is an active element of the experience.
studio.random
studio.random provides genuine random numbers for dice rolls and percentage checks. The model calls roll_dice(query) to roll dice or random_percent(min, max) for a bounded random value. Results are logged so the history is auditable.
This agent exists because models are notoriously bad at being genuinely random. When a model "rolls" a d20 in context, the result is a plausible-sounding number, not a real random number — and perceptive users will notice that critical hits happen at dramatically convenient moments and that the variance is suspiciously low. Using studio.random makes every roll verifiably unbiased and every percentage check genuinely unpredictable.
Dice query syntax
The roll_dice query is a pipe-separated list of dice specs. Each spec is either a plain die size or a die with bounds:
| Query | What it rolls |
|---|---|
6 | One d6, result 1–6 |
6|6 | Two d6, one result each |
6|6|6 | Three d6 |
20 | One d20, result 1–20 |
100>5<30 | One d100, result bounded between 5 and 30 |
6|6|100>10<20 | Two d6 and a d100 bounded 10–20 |
The >N suffix sets a lower bound; <N sets an upper bound. Both can be combined on the same die. random_percent takes optional min and max values (defaulting to 0 and 100) and returns a single integer in that range.
Wiring it into your system prompt
Unlike most agents, studio.random has no preload context block — it contributes nothing until the model calls a tool. The model won't use it unless your system prompt explicitly tells it to. Add instructions like the following to your system prompt to activate it:
## Random Outcomes
When the outcome of an action is uncertain, call roll_dice to determine the result
before writing your response. Do not invent or guess outcomes — roll first, then narrate.
Combat hits: roll_dice("20") — 15+ hits, 10-14 is a graze, below 10 misses.
Damage: roll_dice("6|6") for standard weapons, roll_dice("12") for heavy weapons.
Skill checks: roll_dice("20") — compare against the relevant difficulty (easy 8,
medium 13, hard 18).
Random encounters: random_percent(1, 100) — below 20 triggers an encounter.
The exact thresholds and dice sizes depend entirely on your character's game system. Write the rules in your system prompt the same way you'd explain them to a new player — the model follows them precisely as long as they're unambiguous.
studio.history-search
studio.history-search gives the model — and you — the ability to search the full conversation history by keyword or regular expression, even when that history extends far beyond the model's effective context window. The model calls search_history(query) and receives matching message excerpts. You can also search directly from the command dialog: search betrayal.
This agent is most valuable in complex, long-running narratives where important details were established many sessions ago. A mystery character who needs to recall exactly what the suspect said three sessions back. A detective who needs to find every mention of a particular name. A political character who needs to surface a specific promise made in turn twelve of a hundred-turn conversation. History-search makes any of this retrieval instant and reliable rather than dependent on whether that detail happens to still be in the active context window.
Composable Data Lookup Agents
The agents above are standalone — enable them by string ID and they're ready to use. Character Studio also ships two composable agents designed to be instantiated once per data file. Each instance gets its own tool name derived from the instance id, so you can expose multiple data sources simultaneously without naming conflicts.
Composable agents are configured differently from standalone agents. Instead of a bare string ID, each entry in the "agents" array is an object specifying the implementation to use, the instance id, and the file to target:
{
"agents": [
"studio.memory-store",
{ "id": "char.cars-db", "implementation": "studio.agent-db", "contextFile": ".cars.json" },
{ "id": "char.car-lore", "implementation": "studio.context-access", "contextFile": ".car-lore.txt" }
]
}
The id determines the tool name the model uses to call the agent. Studio derives the tool name automatically by replacing dots and hyphens with underscores and dropping any common prefix: char.cars-db becomes cars_db. This derived name appears in the tool definitions the model sees, and it's how you should refer to the agent in your system prompt: "Use the cars_db tool to look up car records."
Composable agents have no preload context block — they contribute nothing until the model calls their tool. This makes them lightweight: unlike memory or quest agents, they add no token overhead to conversations where the data isn't consulted.
studio.context-access
studio.context-access makes a dot-prefix context file searchable by keyword or regular expression, returning matching text excerpts centered on each hit. Use it for reference material that is too large to include in the system prompt but that the model should be able to consult on demand: name lists, rule books, lore glossaries, vocabulary lists, item catalogs written in prose format.
You can think of this as RAG-lite. It provides contextual block lookups based on simple keyword or phrase searches. This is for Non-structured data. If your data is well structured then you should compose with agent-db instead, which has a full query syntax allowing for fast, structured lookups.
Configuration example — a character that can search a list of baby names:
{ "id": "char.baby-names", "implementation": "studio.context-access", "contextFile": ".baby-names.txt" }
The model calls the derived tool baby_names with a search query and receives up to five matching excerpts by default (up to ten). Matching is case-insensitive substring search by default. Set isRegex: true in the tool call to pass a JavaScript regular expression instead.
This agent is the right choice when your data is prose, semi-structured text, or a format too irregular for field-level querying. For structured tabular data (JSON arrays), prefer studio.agent-db below.
System prompt guidance:
When suggesting a baby name, use the baby_names tool to search the name list
before proposing anything. Always retrieve at least two options. Explain the
origin or meaning of each name you suggest.
studio.agent-db
studio.agent-db turns a dot-prefix JSON file into a queryable database the model can filter by any combination of field values. Unlike studio.context-access (keyword search over text), this agent understands structured data and lets the model express precise, composable filter conditions using a simple YAML syntax. Use it for tabular data: item catalogs, character rosters, location directories, equipment lists, spell databases — any JSON array where the model needs to find records by specific attribute.
The JSON file must contain a top-level array, or an object whose first array-valued property will be used as the data source. Configuration example:
{ "id": "char.cars-db", "implementation": "studio.agent-db", "contextFile": ".cars.json" }
The model calls the derived tool cars_db with a query parameter — a YAML filter expression — and receives matching records as JSON. The optional maxResults parameter caps the result count (default 10, maximum 50). The optional fields array lists specific field names to return, keeping responses concise when records have many fields.
Query syntax
The query is a YAML snippet where each line filters on a specific field. Top-level pairs are combined with AND by default:
manufacturer: Ford
color: red
This returns records where manufacturer is exactly Ford and color is exactly red. All string comparisons are case-insensitive. Numeric fields support numeric operators. Pass * as the entire query to list all records up to maxResults.
Supported value operators:
| Syntax | Meaning | Example |
|---|---|---|
value | Exact match (case-insensitive) | color: red |
~value | Contains | color: ~red |
value... | Starts with | color: red... |
...value | Ends with | color: ...red |
!value | Not equal | color: !red |
>N <N >=N <=N | Numeric comparison | horsepower: >300 |
N..M | Numeric range (inclusive) | horsepower: 200..400 |
[A, B, C] | Value is one of these (case-insensitive) | type: [muscle, supercar] |
To combine conditions with OR, use an or: block with indented conditions. Logic blocks are nestable:
# OR: cars that are red or blue
or:
color: red
color: blue
# AND with nested OR: Ford cars that are red or a muscle car
manufacturer: Ford
or:
color: red
type: muscle
# Explicit nesting with and: block (equivalent to the above)
and:
manufacturer: Ford
or:
color: ~red
type: muscle
Exploring the database
Before constructing a query, the model may need to know what fields are available or what values a field contains. Two parameters handle this:
| Parameter | What it does | Example |
|---|---|---|
schema: true |
Returns the field names from the first record | "What fields does this database have?" |
distinct: "fieldName" |
Returns all unique values for that field, sorted | "What car types are available?" → distinct: "type" |
Both are mutually exclusive with query and jmesPath. Getting field names uses JMESPath's keys() function internally; deduplication for distinct is handled in JavaScript since JMESPath has no native unique operation. Numeric fields are sorted numerically; string fields alphabetically.
Raw JMESPath
For queries the YAML syntax cannot express, the tool also accepts a jmesPath parameter containing a raw JMESPath filter condition — the expression that would go inside [?...]. The agent wraps it against the correct array automatically. Use this when you need JMESPath built-in functions, sub-expression traversal, or any logic more complex than the YAML operators cover.
# YAML (most queries)
horsepower: >300
type: [muscle, supercar]
# Raw JMESPath (advanced — use jmesPath parameter, not query)
contains(engine, 'V8') && year < `1975`
query and jmesPath are mutually exclusive — provide one or the other. The YAML syntax covers the vast majority of lookup needs; reach for jmesPath only when the YAML operators fall short.
System prompt guidance:
You have access to a car database via the cars_db tool. Use it whenever the
user asks about specific cars or wants to find cars matching certain criteria.
Use the fields parameter to keep results concise — fields: ["name",
"manufacturer", "year", "horsepower"] is usually enough for a quick answer.
Only fetch all fields when the user specifically asks for full details.
Use the query parameter with YAML syntax for most lookups:
- "Show me red Italian supercars" → country: Italy\ntype: supercar\ncolor: ~red
- "Anything with over 400 horsepower" → horsepower: >400
- "A Ford or Dodge muscle car" → type: muscle\nmanufacturer: [Ford, Dodge]
For complex conditions the YAML cannot express, use the jmesPath parameter
with a raw JMESPath filter condition instead.
Custom Agent Files
If what you want is a specialist the model can consult — a lore expert, a naming assistant, an NPC with private knowledge — you may not need code at all. Sub-agents (the next chapter) register exactly like the agents here but are authored as plain markdown prompts, buildable from a form, and can even be granted access to the real agents on this page. Reach for a custom JS agent when the job genuinely needs logic, not just judgment.
Every built-in agent in the previous sections was written the same way you can write your own. Agents can be authored in TypeScript, JavaScript, Python, or Lua — all four are full peers with the same manifest, the same exports contract, and the same studio bridge. Character Studio's Agents tab in the file editor is a full TypeScript/JavaScript editor with Monaco's language service running — real-time type checking, IntelliSense completions, and error squiggles as you type — and a Python/Lua editor with syntax highlighting and save-time syntax checking. Agent files live in the agents/ subdirectory of your project.
To create a new agent file, open the Agents tab and click the + button. Studio prompts for a filename and creates a scaffolded file with the manifest comment block pre-filled — all you need to do is fill in the fields and implement your exports. Valid agent IDs use a namespace prefix (your character name or a personal identifier) followed by a dot and a descriptive name: mychar.battle-manager, kristin.baby-name-picker. The studio. prefix is reserved for built-in agents.
Once you have a file, add its ID to your config.json agents array and save. If you open an agent file that isn't in the config yet, an amber notice bar appears below the tab row to remind you. Studio won't load an agent that isn't declared in config — the file must be listed to become active.
JS/TS agent files are standard ES modules — use export function syntax for all exports. You may write TypeScript or plain JavaScript; Studio runs every agent file through the TypeScript transpiler automatically regardless. Type errors appear as squiggles in the editor and as a warning banner above the editor; the file can still be saved with type errors, but cannot be exported in a shareable zip until they are fixed. Python agents (.py) follow the same rules with a docstring manifest and module-level functions — see Python Agents below. Lua agents (.lua) do the same with a --[[ ]] block-comment manifest and global functions — see Lua Agents.
The full authoring API — the manifest fields, the exported function signatures, the store object, and the studio bridge — is documented in Chapter 10, Part 1. The short version: your agent exports onLoad (called at conversation start, returns a context string), onToolCall (called when the model uses one of your tools, returns a result string), and named functions for any @command entries in the manifest.
To debug your agent during development, add @debug true to the manifest. This enables the Agent Log for that agent, letting you inspect every call and its result in real time.
If your agent needs to inject a fixed behavioral instruction into every conversation — a rule that never changes and doesn't depend on stored state — use @context in the manifest instead of @preload. A @context string is injected at conversation load just like a preload block, but requires no onLoad export and can never go stale:
/**
* @agent mychar.datetime
* @context Always call a datetime tool to get the current time — never use a previously seen timestamp, as it may be hours old.
* @preload false
*/
Multiple @context lines are joined with newlines, so long instructions can be split across lines. The injection happens regardless of whether @preload is set, making @context usable on its own without defining onLoad at all. The rule of thumb: use @preload (with onLoad) when the context block needs to be computed from the agent's live state at conversation load time; use @context when the message is a fixed instruction that doesn't vary with state. Both can be used together on the same agent.
Python Agents
Agents can be written in Python as full peers to JavaScript and TypeScript: the same manifest tags, the same lifecycle hooks, the same studio bridge, the same stores, and the same packaging. Name the file with a .py extension (the + button and drag-and-drop both accept it) and declare it in config.json exactly as you would any other agent. Python agents run in a sandboxed CPython interpreter (Pyodide) inside the same worker that runs JS agents — the runtime loads lazily the first time a project actually uses a .py agent, so characters without Python pay nothing.
The manifest lives in the module docstring instead of a comment block, with the identical tag vocabulary:
"""
@agent mychar.inventory
@description Tracks the party's shared inventory
@tool add_item
@tool-description Add an item to the party inventory
@tool-params {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}
@command @inv invCommand
@preload true
@api studio.model
"""
async def on_load(store, studio, context):
items = store.get("items", [])
if not items:
return None
return "## Inventory\n" + "\n".join(f"- {i['name']}" for i in items)
async def on_tool_call(tool_name, args, store, studio, context):
if tool_name == "add_item":
reply = await studio.callModel("appraise this", [], 200)
store.setdefault("items", []).append(args)
studio.log(f"added {args['name']}")
return f"Added {args['name']} — {reply}"
return f"[Unknown tool: {tool_name}]"
def invCommand(args, store, studio, context):
studio.toast(f"{len(store.get('items', []))} items held")
The mapping from the JS contract: onLoad becomes on_load, onToolCall becomes on_tool_call, and @command handlers use the exact name declared in the manifest. Functions may be async def or plain def. The store is a real Python dict — mutate it freely, exactly as JS agents mutate their store object — and it must stay JSON-serializable. The studio bridge keeps its JavaScript names verbatim (studio.log, studio.toast, await studio.callModel(...), await studio.context.readFile(...)), so the bridge reference in the developer reference applies to Python unchanged. Return-value semantics are identical too: tool calls return the string sent to the model, on_load returns an injection string or None, and command handlers return None on success or a usage string to signal a validation error.
Write against Pyodide's standard library — that is the reference runtime and the baseline every player honors. No sockets, no subprocesses, no threads, no filesystem: all I/O goes through the studio bridge, the same rule JS agents live by. Module-level globals last only as long as the sandbox; anything that must survive goes in the store. @lib is JavaScript-only — a Python agent declaring it gets a load warning and no libraries.
Two starter files ship alongside sample-agent.js: sample-agent.py is the rename-and-go template with one tool, one command, and a preload; sample-composable.py shows the composable pattern (per-instance config.json entries, the derived tool name, and reading a context file through the bridge) in Python.
Practical notes: the first Python agent load in a session initializes the interpreter, which takes a few seconds — later loads are instant. Python files get syntax checking on save and tab switch (the same amber bar TypeScript uses); a file with syntax errors can be saved but not exported. Exports ship .py source as-is — there is no compile step — and the unverified agent warning treats Python exactly like JavaScript: code is code.
Lua Agents
Lua is the fourth agent language, and if you come from game modding it may be the most familiar of the lot. Everything the previous section says about Python peers applies verbatim: same manifest tags, same lifecycle hooks, same studio bridge, same stores, same packaging. Name the file with a .lua extension, declare it in config.json as usual, and it runs on a real Lua 5.4 interpreter (Wasmoon, compiled to WebAssembly) inside the same sandboxed worker. The runtime is tiny — about 420 KB, some thirty times smaller than the Python runtime — and loads lazily on the first .lua agent, initializing in tens of milliseconds.
The manifest lives in a --[[ ... ]] block comment at the top of the file (plain --[[ ]] only — long-bracket forms like --[==[ ]==] are not recognized), with the identical tag vocabulary:
--[[
@agent mychar.inventory
@description Tracks the party's shared inventory
@tool add_item
@tool-description Add an item to the party inventory
@tool-params {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}
@command @inv invCommand
@preload true
@api studio.model
]]
function on_load(store, studio, context)
if not store.items or #store.items == 0 then return nil end
local lines = {}
for _, item in ipairs(store.items) do
lines[#lines + 1] = "- " .. item.name
end
return "## Inventory\n" .. table.concat(lines, "\n")
end
function on_tool_call(tool_name, args, store, studio, context)
if tool_name == "add_item" then
local reply = studio.callModel("appraise this", {}, 200):await()
store.items = store.items or {}
store.items[#store.items + 1] = { name = args.name }
studio.log("added " .. args.name)
return "Added " .. args.name .. " — " .. reply
end
return "[Unknown tool: " .. tool_name .. "]"
end
function invCommand(args, store, studio, context)
studio.toast(#(store.items or {}) .. " items held")
end
The mapping from the JS contract is the same as Python's: onLoad becomes on_load, onToolCall becomes on_tool_call, and @command handlers use the exact name declared in the manifest. The store is a real Lua table — mutate it freely; it must stay JSON-serializable (no functions, no cyclic tables). The studio bridge keeps its JavaScript names verbatim, with one visible syntax difference: Lua has no await keyword, so bridge promises are awaited with a method call — studio.callModel(...):await(). Wrap awaited calls in pcall where you want to catch a bridge error and degrade gracefully. Return-value semantics are identical, with nil standing in for null/None.
The sandbox exposes Lua 5.4's standard library minus everything that does I/O or loads code: string, table, math, utf8, coroutine, os.time/os.date/os.clock/os.difftime, and the basic functions (pairs, ipairs, pcall, tostring, tonumber, …). There is no io, no require/dofile/load, no debug, and no os.execute — all I/O goes through the studio bridge, the same rule every language lives by. Globals last only as long as the sandbox; anything that must survive goes in the store. @lib is JavaScript-only.
Three Lua facts worth restating because they differ from the other languages: indexing is 1-based; assigning nil to a table key removes it; and the empty string is truthy — write s ~= "" where a JS agent would rely on falsiness. And one storage caveat unique to Lua's single table type: an empty list serializes as {} rather than [] (a list that still has elements round-trips as a proper array). Write length checks that tolerate both shapes — the store.items = store.items or {} idiom plus # checks, as in the samples — rather than assuming an emptied list stays an array.
Two starter files ship alongside the JS and Python samples: sample-agent.lua is the rename-and-go template, and sample-composable.lua shows the composable pattern in Lua. Lua files get syntax checking on save and tab switch (the same amber bar), a file with syntax errors can be saved but not exported, exports ship .lua source as-is, and the unverified agent warning applies unchanged: code is code.
Library Files
Custom agents sometimes need third-party JavaScript — a date parser, a fuzzy search implementation, a math utilities module. Character Studio supports this through the Libraries tab in the Agents section, which manages a project-level agents/lib/ directory. Libraries are a JavaScript/TypeScript feature — they load into the worker's JS global scope, which Python and Lua agents can never see, so a .py or .lua agent declaring @lib gets a console warning and no libraries.
To add a library, open the Agents tab, click the Libraries sub-tab, and click Import Library…. A file picker opens — choose any .js file and Studio copies it into agents/lib/. The library is then available to any agent in your project.
In your agent's manifest, declare the dependency with @lib:
/**
* @agent mychar.battle-manager
* @lib jmespath
* @lib dice-roller
*/
@lib jmespath resolves to agents/lib/jmespath.js. Studio loads each declared library into the agent's execution environment before the agent module runs, making it available as a global.
Library files must be in IIFE or UMD format — the kind that self-registers on the global scope when evaluated. Most JavaScript libraries offer a browser UMD or IIFE build alongside their ES module build; look for filenames like jmespath.min.js or the dist/ directory. Pure ES modules (files that only use import/export syntax and nothing else) will not work as library files. If you're not sure which format a library uses, the UMD build is usually the safe choice.
Libraries run in the same sandboxed environment as agents — no DOM, no fetch, no localStorage. Pure utility libraries (parsing, math, data manipulation) work fine. Libraries that require network access or browser APIs do not.
Library files are included automatically in shareable exports — anyone who receives your character gets the libraries bundled with it. No CDN dependency, no network requirement at runtime.
The Unverified Agent Warning
When you load a character that includes custom agent code — any agent not in the built-in studio. family — Character Studio shows an amber warning banner above the chat:
"This character uses unverified agent code. Only enable characters from authors you trust."
This is an informed consent prompt, not a block. Agent code runs locally in a sandboxed worker process with no network access, no DOM access, and no ability to read files outside its own store. The sandbox is robust. But the warning exists because you should still know the code is there before you run it, and you should know who wrote it.
Click Dismiss to acknowledge the warning for this project. The acknowledgment is stored in config.json and the banner won't reappear unless a new non-Studio agent is added. If you're the author of the character and the custom agents, you'll dismiss this once during development and never see it again.
The banner does not appear for characters that use only built-in studio.* agents — nor for characters whose only additions are sub-agents, since no character-authored code executes in that case.
Choosing the Right Agents
The right set of agents depends entirely on what your character does. Here's a practical starting point by character type:
| Character type | Recommended agents |
|---|---|
| Simple conversational assistant | None — keep it lightweight |
| Persistent companion with memory | memory-store |
| RPG / adventure character | memory-store, character-stats, quest-log, event-tracker |
| World-explorer / travel guide | memory-store, location, npc-tracker, event-tracker |
| Mystery / detective character | memory-store, history-search, npc-tracker, event-tracker |
| Game master / narrator | All of the above |
| Coding helper / prompt generation | memory-store, history-search, todo-list |
| Character with reference databases or catalogs | context-access, agent-db (composable — one instance per file) |
Start with studio.memory-store alone. It covers 80% of what makes a character feel consistent — the model knows who the user is, what was decided before, and what the relationship currently looks like. Add more agents only when you encounter a specific problem they solve: tracking is missing, time pressure isn't registering, the model forgets where it is. For a scenario that contains long-term events (hours, days, or months) use the studio.event-tracker. Agents you add out of completeness rather than necessity just use tokens without improving the experience.
Writing Agent-Aware System Prompts
Agents inject their state automatically, but the model doesn't know how to use the tools unless you tell it. This is an important and often-overlooked step: enabling an agent without updating your instructions produces a character that has access to memory tools but never calls them, because it has no guidance about when calling them is appropriate.
The most effective approach is to add a section to your Instructions or System Prompt that describes each active agent and provides behavioral guidance for it. The section doesn't need to be long — a few sentences per agent is usually enough — but it should cover three things: what the agent stores, when to call the write tool, and how to interpret the preloaded context block at the start of a conversation.
For memory-store, a guidance paragraph might look like: "You have access to a persistent memory store. When the user tells you their name, save it immediately using set_memory('user_name', ...). When the user expresses a strong preference, fear, or ambition, save it. At the start of each conversation, check the memory block — if the user's name is stored, use it. Never ask the user to remind you of something that's already in memory."
For character-stats, it might be: "You track stats using the stats agent. At the start of combat, always check current HP. When damage is taken, call delta_stat('HP', -amount) immediately. When gold is spent, call delta_stat('gold', -cost). Never do arithmetic on stats in context — always call the delta tool and use the returned value."
After enabling agents and updating your instructions, run a short Playtest session (Chapter 4) with directions like "You are a new user who just started chatting. Give the character your name and a few personal details." Watch the Playtest transcript and verify the model is actually calling the memory tools in response. If it isn't, strengthen the instructions — make the trigger conditions more explicit, and move the agent guidance earlier in the Instructions file where the model's attention is strongest.