DocsBuild your first harness mod

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.

TUTORIALPYTHON + TOMLOFFLINE EXAMPLE

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:

Terminal · bundled example
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:

my_mods.py
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:

my-mods.toml
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:

Terminal · inspect and check
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.

Terminal · run your harness
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:

Expected output · excerpt
"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:

Terminal · live Code Mode
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

Selecting factories
SettingBehavior
--mods PATHSelect a manifest. AGENT_TUI_MODS provides the environment default.
--mod SLOT=FACTORYOverride one manifest selection. Repeat as needed; the last value for a slot wins.
defaultRestore the built-in factory. Existing options are retained, but built-ins do not automatically apply them.
noneDisable planner or subagents. All other slots require an implementation.
--list-modsPrint 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__.py can 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.

Inside a factory
Context valueUse it for
context.optionsA private copy of this slot’s options for the current factory call.
context.settingsThe 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_promptPass the CLI prompt into a replacement app.
context.runtime.allow_delegationDistinguish 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.

Harness component slots
SlotBuilt-in contract / purpose
appAgentApp: synchronous run(); terminal UI and initial prompt.
agentAgent: async run(goal, emit, approve) and aclose(); the TUI also uses its service attributes and history.
loopDefaultLoop: async run(agent, goal, emit, approve); turns, events, and run cleanup.
routerHarnessDecisionLayer: async route(state, tools); route_mcts(state, tools, plan) for planned MCTS.
executorOpenRouterExecutor: async complete(messages, schemas, selected, on_token) returning Completion.
plannerPlanner: async plan(state, tools). May be None; defaults to disabled in Code Mode.
promptsDefaultPrompts: build(...) returns messages; the built-in exposes mutable system_prompt.
code_runtimeDefaultCodeRuntime: spec, prompt, adapt, and async execute.
toolsToolRegistry: schemas, descriptors, registration, validation, execution, approval previews, and sanitization.
tool_policyDefaultToolPolicy: async execute; policies and approvals for top-level and nested calls.
tool_bindingsDefaultToolBindings: register and refresh; skill, memory, and delegation tools.
responsesDefaultResponses: as_dict(result) and message(call, result).
contextContextManager: build, clear, remember_turn, compact, history, and stats.
memoryMemoryStore: context, record_run, search, put, and forget.
skillsSkillManager: discovery, activation, catalog, instructions, and resource reads. Plugin refresh rebuilds this factory.
pluginsPluginManager: skill roots, bootstrap, installed plugins, catalog, and plugin management.
extensionsExtensionConfig: server and hook definitions used by the default MCP and middleware factories.
middlewareMiddlewareManager: async authorize and dispatch, plus hooks and handlers.
mcpMCPManager: async connect(registry, approve, emit) and aclose(); status for the TUI.
subagentsSubagentManager: 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:

Python · inside a tools factory
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:

run_mods.py · save in the repository root
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())
Terminal · Python composition
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

Common mod errors
SymptomWhat to check
Cannot load modCheck the file path relative to the manifest, the factory name, and imports in the active environment.
must be synchronousDeclare the factory with def, not async def. Component methods can be async.
Unknown mod slotUse an exact slot name from the table above; manifest version must be integer 1.
cannot be disabledOnly planner and subagents accept none.
Mod dependency cycleFollow the reported slot chain. Use context.default() to extend the current slot instead of resolving it with context.get().
Docs workshop is missingUse Code Mode and select my-mods.toml. The bundled manifest uses a different label; the routed example only lists files.
Edits have no effectRestart the app. Verify the selected targets with --list-mods and check the manifest directory.

Compare your files with the bundled manifest and example factories.