Build your first harness mod
Extend a prompt, add a workspace tool, and select your components with a TOML manifest. Run the whole example without an API key.
Before you start
Complete the installation steps first. Work from the PyLattice repository root with Python 3.11+ and dependencies installed using uv sync.
A mod is a Python factory that fills a named harness slot: prompts, tools, a provider, the run loop, or even the terminal app. Unspecified slots use built-ins, so you can start with a small change.
Python with the app’s permissions. Mod factories are trusted application code. They run outside the Code Mode sandbox and load only when explicitly selected. Review a mod before using it.
To see the finished bundled example before writing your own:
uv run agent-tui --mods examples/mods.toml --code-mode --prompt "Inspect this workspace"
Expect a workspace listing and the label My modded workspace. The example providers use the real loop and tools, but return scripted responses rather than acting as general-purpose language models.
1. Write the factories
Create my_mods.py in the repository root with both factories below:
import json
from agent_tui.mods import ModContext
from agent_tui.tools import ToolResult, ToolSpec, object_schema
def build_prompts(context: ModContext):
prompts = context.default()
prompts.system_prompt += "\n" + context.options["instructions"]
return prompts
def build_tools(context: ModContext):
registry = context.default()
async def workspace_label(arguments):
return ToolResult(True, json.dumps(context.options["label"]))
registry.register(
ToolSpec(
"workspace_label",
"Return the label for this workspace.",
object_schema({}),
),
workspace_label,
)
return registry
context.default() builds the current slot’s built-in implementation using its configured dependencies. The prompt factory appends instructions; the tools factory registers workspace_label alongside the existing tools.
The tool takes an empty argument object. Its async handler returns a ToolResult; the JSON-encoded label becomes the native output value in tool replies. This read-only tool uses the default low risk level.
2. Select your slots
Create my-mods.toml beside my_mods.py. Reuse the bundled offline router and executor so you can verify your own prompt and tool factories without credentials:
version = 1
[mods]
router = "examples/harness_mods.py:build_router"
executor = "examples/harness_mods.py:build_executor"
planner = "none"
prompts = "my_mods.py:build_prompts"
tools = "my_mods.py:build_tools"
[options.prompts]
instructions = "Explain which observations support your answer."
[options.tools]
label = "Docs workshop"
Each [mods] entry maps a slot to path.py:factory or importable.module:factory. An [options.SLOT] table becomes that factory’s context.options. Here, the planner is explicitly disabled.
Keep these files at the repository root for this tutorial: Python file targets resolve relative to the manifest directory, which makes the examples/harness_mods.py targets work.
3. Inspect and run
First inspect the effective factory selection, then exercise the providers:
uv run agent-tui --mods my-mods.toml --list-mods
uv run agent-tui --mods my-mods.toml --check
--list-mods does not import custom Python. Confirm that prompts and tools point at my_mods.py. The connection check constructs the router, executor, and tools; it should report WorkspaceRouter, WorkspaceExecutor, and tool calling works via example/offline. It does not validate every slot.
uv run agent-tui --mods my-mods.toml --code-mode --prompt "Inspect this workspace"
Expand the execute_code trace. The example calls workspace_label and list_files, then finishes with Custom harness observed:. The returned data includes:
"label": "Docs workshop"
Change the label in TOML and restart the app to see the new value. The offline executor is scripted, so it demonstrates the tool output but does not interpret your added prompt instructions.
You can also try --no-code-mode with the same manifest. In that mode, the example lists files directly and does not call workspace_label.
4. Use live providers
Once you have configured OPENROUTER_API_KEY as described in getting started, restore the built-in providers with CLI overrides. Your prompt and tool factories stay selected:
uv run agent-tui --mods my-mods.toml --code-mode \
--mod router=default --mod executor=default \
--prompt "Read the workspace label and explain what you find."
This command uses live model connections. The live executor can follow the prompt instructions and decide when the new tool is useful. To restore the built-in planner for the planned per-tool loop, also use --mod planner=default with --no-code-mode.
Configuration reference
| Setting | Behavior |
|---|---|
--mods PATH | Select a manifest. AGENT_TUI_MODS provides the environment default. |
--mod SLOT=FACTORY | Override one manifest selection. Repeat as needed; the last value for a slot wins. |
default | Restore the built-in factory. Existing options are retained, but built-ins do not automatically apply them. |
none | Disable planner or subagents. All other slots require an implementation. |
--list-mods | Print effective targets without importing custom code. |
- Relative manifest paths resolve from
--workspace. - Python file targets resolve from the manifest directory, or the workspace when no manifest is selected. This also applies to file targets supplied by
--mod. - Module targets must be importable in the active Python environment. A package’s
__init__.pycan be a file target when relative imports are needed. - Manifests use
version = 1. PyLattice does not automatically discover or import.agent-tui/mods.toml. - Restart the app after changing factory selection or Python modules; there is no hot reload.
Factory contract
A factory is a synchronous callable accepting one ModContext and returning a component. Put asynchronous I/O in component methods. A component may wrap a built-in, subclass it, or provide a different implementation with the required interface.
| Context value | Use it for |
|---|---|
context.options | A private copy of this slot’s options for the current factory call. |
context.settings | The current workspace and application settings. |
context.default() | Create the current slot’s built-in implementation without recursively calling your replacement. |
context.get("slot") | Resolve another configured component, cached within this runtime. |
context.runtime.initial_prompt | Pass the CLI prompt into a replacement app. |
context.runtime.allow_delegation | Distinguish the root runtime from delegated children. |
Factories should return fresh stateful instances. Child agents share the factory definitions and options, but receive fresh runtimes, providers, histories, and registries. Delegated runtimes return None for subagents, preserving the default no-nested-delegation behavior.
Available slots
These are the boundaries used by the built-in consumers. The loader checks main callable entry points, not every argument, attribute, or result type. Keep the consumer’s contract, or replace that consumer too.
| Slot | Built-in contract / purpose |
|---|---|
app | AgentApp: synchronous run(); terminal UI and initial prompt. |
agent | Agent: async run(goal, emit, approve) and aclose(); the TUI also uses its service attributes and history. |
loop | DefaultLoop: async run(agent, goal, emit, approve); turns, events, and run cleanup. |
router | HarnessDecisionLayer: async route(state, tools); route_mcts(state, tools, plan) for planned MCTS. |
executor | OpenRouterExecutor: async complete(messages, schemas, selected, on_token) returning Completion. |
planner | Planner: async plan(state, tools). May be None; defaults to disabled in Code Mode. |
prompts | DefaultPrompts: build(...) returns messages; the built-in exposes mutable system_prompt. |
code_runtime | DefaultCodeRuntime: spec, prompt, adapt, and async execute. |
tools | ToolRegistry: schemas, descriptors, registration, validation, execution, approval previews, and sanitization. |
tool_policy | DefaultToolPolicy: async execute; policies and approvals for top-level and nested calls. |
tool_bindings | DefaultToolBindings: register and refresh; skill, memory, and delegation tools. |
responses | DefaultResponses: as_dict(result) and message(call, result). |
context | ContextManager: build, clear, remember_turn, compact, history, and stats. |
memory | MemoryStore: context, record_run, search, put, and forget. |
skills | SkillManager: discovery, activation, catalog, instructions, and resource reads. Plugin refresh rebuilds this factory. |
plugins | PluginManager: skill roots, bootstrap, installed plugins, catalog, and plugin management. |
extensions | ExtensionConfig: server and hook definitions used by the default MCP and middleware factories. |
middleware | MiddlewareManager: async authorize and dispatch, plus hooks and handlers. |
mcp | MCPManager: async connect(registry, approve, emit) and aclose(); status for the TUI. |
subagents | SubagentManager: begin_run, end_run, and async execute. May be None. |
For exact method signatures, see the typed protocols and the full component reference. Shared values live in models.py, tools.py, and planner.py.
The default loop reserves execute_code with a code argument and finish with a summary argument. Preserve the standard ok, output, error, and truncated response fields, or replace their prompt and runtime consumers together. A different agent interface may also need a replacement app.
Set requires_api_key = True on an executor only when it uses the OpenRouter key. Other providers handle their own credentials.
Replace individual tools
A tools factory can call context.default() to get the registry, then register, replace, or remove tools:
registry.register(spec, handler) # Add; duplicate names are rejected.
registry.replace(spec, handler) # Replace, including a built-in.
registry.unregister("run_command") # Remove a tool.
Here, spec is your ToolSpec and handler is an async callable receiving validated arguments and returning ToolResult. Label risk accurately; the built-in registry uses it for read-only filtering and approvals, and applies validation, sanitization, and output limits.
Skill, memory, and delegation tools are bound later; customize tool_bindings for those. MCP tools are installed by mcp.connect() each run. Replacing tool_policy replaces the policy boundary, so your implementation must supply the approval rules you need.
Compose and clean up
You can also select factories in Python. This complete script uses the two factories above and the bundled offline providers:
import asyncio
from pathlib import Path
from agent_tui.config import Settings
from agent_tui.mods import HarnessMods, ModRuntime, ModSpec
from my_mods import build_prompts, build_tools
async def emit(event):
pass
async def approve(title, detail):
return False
async def main():
workspace = Path(__file__).resolve().parent
settings = Settings(workspace=workspace, memory_enabled=False)
mods = HarnessMods(
{
"router": "examples/harness_mods.py:build_router",
"executor": "examples/harness_mods.py:build_executor",
"planner": "none",
"prompts": ModSpec(build_prompts, {
"instructions": "Explain the observations."
}),
"tools": ModSpec(build_tools, {"label": "Docs workshop"}),
},
base=workspace,
)
async with ModRuntime(settings, mods) as runtime:
agent = runtime.get("agent")
result = await agent.run("Inspect this workspace", emit, approve)
print(result.message)
asyncio.run(main())
uv run python run_mods.py
The runtime calls each distinct component’s optional aclose() in reverse construction order, including rebuilt components. It attempts all closers and reports grouped failures. Closing the runtime twice is harmless.
Close resources your component owns, not dependencies obtained through context.get(). The default loop closes MCP connections after each run, so a custom MCP component must support reconnecting and repeated closes. The CLI always closes its runtime, including when app construction or execution fails.
Troubleshooting
| Symptom | What to check |
|---|---|
Cannot load mod | Check the file path relative to the manifest, the factory name, and imports in the active environment. |
must be synchronous | Declare the factory with def, not async def. Component methods can be async. |
Unknown mod slot | Use an exact slot name from the table above; manifest version must be integer 1. |
cannot be disabled | Only planner and subagents accept none. |
Mod dependency cycle | Follow the reported slot chain. Use context.default() to extend the current slot instead of resolving it with context.get(). |
Docs workshop is missing | Use Code Mode and select my-mods.toml. The bundled manifest uses a different label; the routed example only lists files. |
| Edits have no effect | Restart the app. Verify the selected targets with --list-mods and check the manifest directory. |
Compare your files with the bundled manifest and example factories.