Open Mode Meta-Tools¶
In open mode (execution_mode=ExecutionMode.OPEN) an agent process gets meta-tools: tools that change the process itself instead of the outside world. With them the agent can rewrite its instructions, write new Python tools, connect MCP servers, add and remove its own triggers, manage long-term memory and spawn helper processes. Every meta-tool goes through the same process methods an operator would use, so the limits in OpenModeConfig and your hooks apply to each change.
Open mode hands the model control of its own setup
Whoever can put text in front of the model (trigger payloads, inbox messages, tool results) can try to steer these tools. create_tool in particular runs model-written Python inside the runtime's process; its restricted builtins are not a sandbox. Read Security before you enable open mode, and switch off what the agent doesn't need.
import asyncio
from promptise.runtime import AgentRuntime, ExecutionMode, OpenModeConfig, ProcessConfig
config = ProcessConfig(
model="openai:gpt-5-mini",
instructions="You are an adaptive monitoring agent. Improve your own tooling over time.",
execution_mode=ExecutionMode.OPEN,
open_mode=OpenModeConfig(
allow_tool_creation=False, # no model-written Python in this process
allow_mcp_connect=False, # no new servers
allow_process_spawn=True, # may start helper processes
max_custom_tools=10,
max_spawned_processes=2,
max_rebuilds=20,
),
)
async def main():
async with AgentRuntime() as runtime: # spawn_process needs a runtime
await runtime.add_process("monitor", config)
await runtime.start_all()
await asyncio.sleep(3600)
asyncio.run(main())
Available meta-tools¶
create_meta_tools() builds the list when the agent is built, filtered by OpenModeConfig. Strict-mode processes get none of them.
| Meta-tool | Present when | What it does |
|---|---|---|
modify_instructions |
allow_identity_change |
Replace the system prompt (hot reload) |
create_tool |
allow_tool_creation |
Define a Python tool (hot reload). Runs in the runtime's process, not isolated |
connect_mcp_server |
allow_mcp_connect |
Connect an HTTP MCP server (hot reload) |
add_trigger |
allow_trigger_management |
Add a cron, event, message, webhook, file_watch or custom trigger |
remove_trigger |
allow_trigger_management |
Remove a trigger the agent added |
spawn_process |
allow_process_spawn |
Create and start a new process in the runtime |
list_processes |
allow_process_spawn |
List the runtime's processes |
store_memory |
allow_memory_management |
Store text in long-term memory |
search_memory |
allow_memory_management |
Search long-term memory |
forget_memory |
allow_memory_management |
Delete a memory by ID |
get_secret |
secret scoping enabled | Read a scoped secret |
check_budget |
budget enabled | Show the remaining budget |
check_mission |
mission enabled | Show the mission state and latest evaluations |
list_capabilities |
always (in open mode) | Show the agent's current instructions, tools, triggers and counters |
The update_state(key, value) tool is not a meta-tool: ContextConfig(state_tool=True) gives it to a process in either mode (see Context).
How changes are applied¶
Each meta-tool calls a process method, and every method enforces the limits and dispatches the config_change hook (source="agent"), which can refuse the change:
| Meta-tool | Calls | Limits checked |
|---|---|---|
modify_instructions |
process.set_dynamic_instructions() |
max_instruction_length, max_rebuilds |
create_tool |
hot reload | max_custom_tools, duplicate names, max_rebuilds |
connect_mcp_server |
hot reload | duplicate names, http(s) URL, allowed_mcp_urls (or a public address when it is empty), max_rebuilds |
add_trigger |
process.add_dynamic_trigger() |
max_dynamic_triggers, trigger validation, loopback-only webhooks, the API's data_dir for watch paths, process state |
remove_trigger |
process.remove_trigger(trigger_id, source="agent") |
only triggers the agent added |
spawn_process |
process.spawn_process() |
max_spawned_processes, unique name, loopback-only webhooks; the subagent_start hook can refuse it |
A refused or failed change is not applied, now or after a restart. Tools report problems as text starting with Error, so the model can read them and carry on.
modify_instructions¶
| Parameter | Type | Description |
|---|---|---|
new_instructions |
str |
The complete new system prompt |
The agent is rebuilt with the new instructions. rollback() restores the configured ones, and an operator's process.update_instructions() replaces whatever the agent set.
| Result | When |
|---|---|
Rebuild successful |
Applied |
Error: instructions exceed max length (12000 > 10000) |
Longer than max_instruction_length |
Error: max rebuilds (N) reached; the change was not applied |
max_rebuilds used up |
Error: the change was blocked by a hook: <reason> |
A config_change hook refused it |
create_tool¶
| Parameter | Type | Description |
|---|---|---|
tool_name |
str |
Unique name for the new tool |
tool_description |
str |
What the tool does |
parameters |
dict |
JSON object schema of the tool's parameters (empty: one free-form input string) |
python_code |
str |
Python code defining run(...), which returns a string |
parameters shapes the new tool's inputs. Each property of the object schema is a parameter, offered to the model with its type and constraints, and passed to run as a keyword argument:
create_tool(
tool_name="add",
tool_description="Add two integers.",
parameters={
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
},
python_code="def run(a, b):\n return str(a + b)",
)
# The new tool is called as add(a=2, b=3) -> "5"
- Arguments are validated against the schema before
runis called. Invalid arguments raiseToolArgumentError(the model sees each problem and can correct the call), andrunis not called. rungets the arguments the caller gave, as plain JSON (nested objects are dicts), plus each property's schemadefaultwhen the caller left it out. An optional parameter without a schema default that the caller left out is not passed, sorun's own default applies.- Property names must be usable as Python keyword arguments (identifiers, not keywords, not starting with
_), andrequiredmay only name declared properties. A schema that is not an object schema, or that lists parameters withoutproperties, is refused and no tool is created. - With empty
parameters(or an object schema without properties), the tool takes one free-forminputstring:run(input="...").
The new tool can be called from the agent's next run, not in the run that creates it (the current run keeps the tools it started with). The tool's description and its result say so, so the model finishes the current task without it instead of looking for it or creating it again.
| Result | When |
|---|---|
Tool 'add' created. You can call it from your next run, not in this one: this run keeps the tools it started with, so finish the current task without it. |
Created |
Error: max custom tools (20) reached |
max_custom_tools reached |
Error: tool 'add' already exists |
Duplicate name |
Error creating tool: <reason> |
Unusable parameters schema, or the rebuild failed |
Error: max rebuilds (N) reached; the change was not applied |
max_rebuilds used up |
Error: the change was blocked by a hook: <reason> |
A config_change hook refused it |
Sandbox execution¶
With sandbox_custom_tools=True (the default), agent-written code runs with restricted builtins:
- Allowed:
len,range,str,int,float,list,dict,set,sorted,enumerate,zip,map,filter,max,min,sum,print,round,abs,bool,type,isinstance, common exceptions and other safe builtins. - Blocked:
open,exec,eval,__import__, file I/O, network access and all imports.
With sandbox_custom_tools=False, the code runs with the same restricted builtins (no open, exec, eval, file I/O or network access) but may import a fixed allowlist of standard-library modules: base64, collections, copy, datetime, functools, hashlib, itertools, json, math, re, string, urllib.parse, uuid. Any other import (os, decimal, ...) fails with Import of '...' is not allowed, and each call logs a warning. No setting gives agent-written code full Python builtins; give the agent such capabilities as reviewed MCP tools.
This is not a sandbox, with either setting. The code runs in the runtime's own process, and Python's object model lets code walk from any object back to modules like os, so agent-written code can run commands as the runtime's user, read its memory and environment, and reach anything the runtime can. The restrictions only stop honest code from reaching for files and imports by accident. Treat allow_tool_creation=True as "the model may run arbitrary code here":
- the control API refuses to deploy such a config unless
allow_command_deploysis on (403 COMMANDS_NOT_ALLOWED); - for code from the model, prefer the Docker sandbox (
build_agent(sandbox=...)), which runs each command in an isolated container, or give the agent reviewed MCP tools instead.
connect_mcp_server¶
| Parameter | Type | Default | Description |
|---|---|---|---|
server_name |
str |
required | Unique name for the server |
url |
str |
required | http:// or https:// URL of the MCP server |
transport |
str |
"streamable-http" |
streamable-http or sse |
Only HTTP(S) servers: the agent can't start a stdio server (a program on the host). Which URLs are accepted depends on allowed_mcp_urls:
allowed_mcp_urls |
Operator-configured process | Process deployed through the control API |
|---|---|---|
| Listed URLs | Only those (compared with a lowercase scheme and host, default ports dropped, no trailing slash); a listed private or loopback URL is fine | The same |
| Empty | Any URL whose host resolves to public addresses only: loopback, private, link-local (cloud metadata) and other internal addresses are refused. A warning is logged when the process starts | None: the agent can't connect servers |
The address check resolves the host when the agent connects and again at every agent build, like escalation webhooks, but it can't stop a host whose DNS answer changes in between; list the servers in allowed_mcp_urls where that matters. After connecting, the agent is rebuilt with the server's tools, which are available from the next run.
A server the agent connected never receives the process identity's credential (ProcessConfig.identity) or a caller's token, so the agent can't send them to a server it picked. List the URLs that may receive it in open_mode.identity_mcp_urls.
| Result | When |
|---|---|
Connected to MCP server 'x' at <url>. Its tools are available from your next run, not in this one: this run keeps the tools it started with. |
Connected |
Error: the MCP server URL must start with http:// or https:// |
Not an HTTP(S) URL |
Error: URL not in allowed whitelist (open_mode.allowed_mcp_urls) |
Not in allowed_mcp_urls |
Error: refused: '<host>' resolves to private/internal IP <ip> (...) |
No allowlist, and the host is internal |
Error: this process was deployed through the control API and has no open_mode.allowed_mcp_urls, ... |
API-deployed process without an allowlist |
Error: the MCP server host did not resolve (...) |
No allowlist, and the host doesn't resolve |
Error: server 'x' already connected |
Duplicate name |
Error connecting to MCP server: <reason> |
The connection or rebuild failed |
Error: max rebuilds (N) reached; the change was not applied |
max_rebuilds used up |
Error: the change was blocked by a hook: <reason> |
A config_change hook refused it |
add_trigger¶
| Parameter | Type | Description |
|---|---|---|
trigger_type |
str |
"cron", "event", "message", "webhook", "file_watch" or a registered custom type |
cron_expression |
str \| None |
For cron |
event_type |
str \| None |
For event |
topic |
str \| None |
For message |
webhook_path |
str \| None |
For webhook |
webhook_port |
int \| None |
For webhook |
watch_path |
str \| None |
For file_watch |
watch_patterns |
list[str] \| None |
For file_watch |
custom_config |
dict \| None |
For custom trigger types |
The trigger is validated like any TriggerConfig, started at once with its listener, and counts toward max_dynamic_triggers. It is not added to config.triggers: it lives until remove_trigger, rollback() or the next stop() (a restart drops it too).
A webhook the agent adds listens on loopback only. process.add_dynamic_trigger() refuses a non-loopback webhook_host unless open_mode.allow_public_webhooks is on, and then requires an hmac_secret. For a process deployed through the control API, a file_watch watch_path must lie in the API's data_dir.
| Result | When |
|---|---|
Trigger 'cron-36042769' (cron) added and listening. |
Added; the ID is what remove_trigger takes |
Error adding trigger: max dynamic triggers (10) reached |
Limit reached |
Error adding trigger: Invalid trigger config: <details> |
Invalid fields |
Error adding trigger: Adding the cron trigger was blocked: <reason> |
A config_change hook refused it |
Error adding trigger: Trigger could not start: <reason> |
The trigger failed to start (for example a port in use) |
Error adding trigger: a webhook trigger on '0.0.0.0' would accept requests from other machines; ... |
Non-loopback webhook (see above) |
Error adding trigger: watch_path '...' is outside the data directory ... |
API-deployed process, path outside data_dir |
remove_trigger¶
| Parameter | Type | Description |
|---|---|---|
trigger_id |
str |
ID returned by add_trigger |
The agent can only remove triggers it added; config triggers stay. The trigger's listener is cancelled with it.
| Result | When |
|---|---|
Trigger 'cron-36042769' removed. |
Removed |
Error: trigger 'x' not found in dynamic triggers |
Not an agent-added trigger |
Error removing trigger: Removing trigger 'x' was blocked: <reason> |
A config_change hook refused it |
spawn_process¶
| Parameter | Type | Default | Description |
|---|---|---|---|
process_name |
str |
required | Unique name in the runtime |
instructions |
str |
required | System prompt for the new process |
model |
str \| None |
None |
Model ID; None uses the parent's model |
triggers |
list[dict] |
[] |
Trigger configs (each with type and its fields) |
execution_mode |
str |
"strict" |
"strict" or "open" |
The new process is registered in the parent's AgentRuntime, started, and listed in process.spawned_processes. It inherits the parent's guardrails:
| Inherited from the parent | Not inherited |
|---|---|
budget, health, trigger_delivery and journal settings (copies: the child has its own counters) |
Secrets |
approval policy and guardrails scanner |
MCP servers |
max_invocation_time |
Identity |
open_mode limits, with allow_process_spawn switched off |
config.hooks entries, mission, inbox, context settings |
The parent's hooks: its hooks manager (and what the parent inherited) is dispatched for the child after the child's own, so permission_request, user_prompt_submit and every other hook the parent has also govern the child (ctx.process_id names the child) |
|
The deploy policy: a child of an API-deployed process is limited like its parent (env references, MCP allowlist, data_dir) |
So a spawned process can never spawn more processes, even in open mode. Its triggers follow the add_trigger rules: webhooks listen on loopback unless open_mode.allow_public_webhooks is on (then with an hmac_secret), so the model can't open a public, unsigned endpoint. The subagent_start hook runs before the spawn and can refuse it; subagent_stop runs when the child stops or fails. The child runs independently: stopping the parent does not stop it. allow_process_spawn defaults to False.
| Result | When |
|---|---|
Process 'helper' spawned and started. Mode: strict. Triggers: 1. |
Spawned |
Error: process is not running inside an AgentRuntime. spawn_process requires a runtime. |
The process has no runtime |
Error spawning process: max spawned processes (3) reached |
Limit reached |
Error spawning process: process 'helper' already exists in the runtime |
Name taken |
Error spawning process: invalid process config: <details> |
Bad trigger or mode |
Error spawning process: spawning 'helper' was blocked by a hook: <reason> |
A subagent_start hook refused it |
Error spawning process: can't spawn 'helper': a webhook trigger on '0.0.0.0' ... |
A non-loopback (or unsigned public) webhook trigger |
A child that fails to start is removed from the runtime again, and the error is returned.
list_processes¶
No parameters. Lists every process in the runtime:
Processes in runtime:
- monitor: state=running, invocations=12 (this process)
- helper: state=running, invocations=3 [spawned by this process]
Without a runtime: Error: process is not running inside an AgentRuntime. list_processes requires a runtime.
store_memory, search_memory, forget_memory¶
These need a long-term memory provider (ContextConfig.memory_provider); without one each returns Error: no memory provider configured.
| Tool | Parameters | Result |
|---|---|---|
store_memory |
content: str, tags: list[str] = [] |
Stored in memory (id=<id>). |
search_memory |
query: str, limit: int = 5 (1-20) |
Found memories: and one - [<id>] (score=0.87) <content> line each, or No relevant memories found. |
forget_memory |
memory_id: str |
Memory '<id>' deleted. or Memory '<id>' not found. |
Governance tools¶
These appear in open mode when the subsystem is enabled.
| Tool | Parameters | Result |
|---|---|---|
get_secret |
name: str |
The secret value, or Error: secret '<name>' not found or expired. Each access is logged in the audit trail. |
check_budget |
none | Budget remaining: and one line per limit (Tool Calls Run: 5, Cost Day: unlimited, ...) |
check_mission |
none | The mission summary and up to the latest 10 evaluations (- #<run> [<source>] achieved, confidence=0.82: <reasoning>) |
list_capabilities¶
No parameters. Returns the process name, mode and model, the current instructions (first 200 characters), custom tool names, config and dynamic trigger IDs, spawned processes, memory status, conversation size, and the invocation and rebuild counts:
Process: monitor
Mode: open
Model: openai:gpt-5-mini
Instructions: You coordinate carefully.
Custom tools: ['add']
Static triggers: []
Dynamic triggers: []
Spawned processes: ['helper']
Memory: disabled
Conversation buffer: 0 messages
Invocations: 0
Rebuilds: 2
Security¶
Open mode gives the model the same levers an operator has, within OpenModeConfig. Plan for a model that has been talked into misusing them.
| Capability | Risk | What limits it |
|---|---|---|
create_tool (allow_tool_creation, on by default) |
Runs model-written Python in the runtime's process: effectively any command as the runtime's user | Nothing contains it. Switch it off unless that is acceptable; the control API refuses it without allow_command_deploys |
connect_mcp_server (allow_mcp_connect) |
Sends requests (and tool arguments) to a server the model picked | allowed_mcp_urls; without it, internal addresses are refused and API deploys get none. Agent-connected servers never get the process identity's credential or a caller's token (identity_mcp_urls opts a URL in) |
add_trigger (allow_trigger_management) |
Opens new entry points (webhooks, file watchers) | Webhooks on loopback only unless allow_public_webhooks (then signed); unsigned loopback webhooks refuse browser cross-site requests; API deploys keep watch paths in data_dir |
spawn_process (allow_process_spawn, off by default) |
New processes with their own triggers | Children inherit the guardrails, hooks and deploy policy, can't spawn further, and follow the add_trigger webhook rules |
get_secret (secret scoping on) |
The model reads secret values | Secret values are redacted from the journal, the prompt history, /ask answers and inbox listings; an API deploy can't make a secret out of a host environment variable unless the operator allows it |
Hooks are the place for your own policy: a config_change hook (source="agent") can refuse any self-modification, subagent_start any spawn, and permission_request any approval-gated tool call, for spawned children too.
Hot reload¶
modify_instructions, create_tool and connect_mcp_server rebuild the agent:
- The limits are checked and the
config_changehook runs; a refusal stops here, with nothing changed. - The change is staged and the conversation history saved.
- The agent is rebuilt from the static config plus the dynamic state, with a fresh connection to each MCP server.
- The previous agent and its MCP connections are closed (stdio servers exit) once the run that asked for the reload has finished; until then that run keeps the tools it started with.
- The conversation history is restored and
rebuild_countgoes up.
If the rebuild fails, the process keeps the previous agent and the change is undone, so a later start doesn't apply it either.
max_rebuilds¶
With OpenModeConfig.max_rebuilds set, the process refuses further changes once that many hot reloads have happened. The limit counts over the lifetime of the AgentProcess object: stop() / start(), recovery and automatic restarts don't reset it (status()["rebuild_count"]). A refused change is not made at all; the limit is checked before the change is staged, under the same lock as the rebuild.
rollback¶
await process.rollback() is the operator's undo: it removes the agent's instructions, tools, MCP servers and triggers and rebuilds from the original config. It is not refused by max_rebuilds but counts as a rebuild, and it returns Rebuild successful. It keeps the context state and conversation; use rewind() to roll those back. Strict-mode processes raise RuntimeError.
What survives a restart¶
Agent-written instructions, custom tools and MCP servers stay on the process object, so stop() / start() and automatic restarts rebuild the agent with them. Agent-added triggers end at stop(). A new process object (a redeploy) starts from the config only.
Programmatic use¶
from promptise.runtime.meta_tools import create_meta_tools
tools = {t.name: t for t in create_meta_tools(process, runtime=runtime)}
print(await tools["add_trigger"].ainvoke({"trigger_type": "cron", "cron_expression": "*/5 * * * *"}))
# Trigger 'cron-36042769' (cron) added and listening.
create_meta_tools(process, *, runtime=None) returns LangChain StructuredTools filtered by process.config.open_mode. Without runtime, spawn_process and list_processes answer with the "not running inside an AgentRuntime" error. Calling the tools directly is useful in tests; the process adds them to its own agent by itself.
Common pitfalls¶
- Spawning without a runtime. A bare
AgentProcesscan't spawn; register it withAgentRuntime.add_process()(which passes the runtime) or passruntime=. - Expecting the new tool in the same run. Created tools and connected servers are available from the next run.
- Leaving
max_rebuildsunset. Each hot reload reconnects every MCP server. A cap bounds both the cost and how far the agent can drift. - Assuming a child has the parent's secrets or tools. A spawned process gets the parent's limits and hooks, not its secrets or servers.
- Treating the sandbox as isolation. Restricted builtins don't contain hostile code:
create_toolcan run any command as the runtime's user. Setallow_tool_creation=Falseunless that is acceptable, and use the Docker sandbox or MCP tools for anything sensitive. - Empty
allowed_mcp_urls. For your own process it means any public HTTP(S) URL (a warning says so at start); for an API deploy it means none. List the servers the agent may use, or setallow_mcp_connect=False.
What's next¶
- Configuration -- every
OpenModeConfigfield - Runtime Hooks -- refuse an agent's change with a
config_changeorsubagent_starthook - Runtime Manager -- the runtime spawned processes join
- Triggers Overview -- the trigger types
add_triggercan create - Context -- the state and memory the memory tools use
Learn the concept
- Self-Modifying AI Agent in Python: Open Mode & Limits -- Meta-tools, hot reload and the limits that keep open mode bounded.
- Autonomous AI Agent Runtime: Budgets, Crash Recovery, API -- The budgets and controls around an agent that changes itself.