Skip to content

Authoring tools

In this guide you'll build a tool end to end: a ScriptTool handler with typed input/output, a ToolDefinition that compiles it into an agent (as a bash allowlist entry, a JSON-schema custom tool, or both), a deterministic mock so tests never need credentials, and an AccessProfile that routes the tool to a real database in prod and an in-memory one in CI.

1. Write the ScriptTool handler

Every script-backed tool subclasses ScriptTool[Input, Output] from open_agent_compiler.runtime and lives in your project's scripts/:

# scripts/time_tool.py
from pydantic import BaseModel, Field
from open_agent_compiler.runtime import ScriptTool

class TimeInput(BaseModel):
    timezone_hours: int = Field(default=0, description="Offset from UTC in hours.")

class TimeOutput(BaseModel):
    iso: str
    timezone_hours: int

class TimeTool(ScriptTool[TimeInput, TimeOutput]):
    name = "time-tool"
    description = "Return current UTC time (optional timezone offset)."

    def execute(self, input: TimeInput) -> TimeOutput:
        import datetime
        tz = datetime.timezone(datetime.timedelta(hours=input.timezone_hours))
        return TimeOutput(iso=datetime.datetime.now(tz).isoformat(),
                          timezone_hours=input.timezone_hours)

if __name__ == "__main__":
    TimeTool.run()

ScriptTool.run() generates an argparse CLI from the input model, so the compiled agent invokes it as uv run scripts/time_tool.py --timezone_hours 1, or pipes JSON via --json + stdin. Output is printed as JSON. The built-in --mock flag (or the OAC_MOCK_RESPONSE env var) short-circuits execute() and returns a mock instead — that's what test variants use.

2. Bind external systems with execute(input, resources)

Tools that touch a DB or API opt into a second resources kwarg. The runtime inspects your signature: the legacy (self, input) shape keeps working, and (self, input, resources) receives a dict[str, ResourceHandle] parsed from the OAC_RESOURCES_JSON env var:

from open_agent_compiler.runtime import ResourceHandle, ScriptTool

class NotesTool(ScriptTool[NotesInput, NotesOutput]):
    name = "notes-db"
    description = "Persistent notes via a bound SQLite resource."

    def execute(
        self, input: NotesInput,
        resources: dict[str, ResourceHandle] | None = None,
    ) -> NotesOutput:
        if not resources or "notes_db" not in resources:
            return NotesOutput(ok=False)
        conn = resources["notes_db"].sqlite_connect()
        try:
            ...  # use conn
        finally:
            conn.close()
        return NotesOutput(ok=True)

ResourceHandle carries the binding's kind and config from the active AccessProfile. sqlite_connect() opens a real sqlite3.Connection from config["path"] and raises clearly on a wrong kind or a mock_only binding. examples/33_sqlite_resources/ is the runnable demo.

3. Declare the ToolDefinition

The compile-time side is a ToolDefinition. It can carry both invocation contracts — bash_tool (an allowlisted bash command) and json_tool (an OpenCode custom tool with a JSON schema derived from your input model):

from open_agent_compiler import (
    BashToolPermission, JsonToolPermission, MockResponse, ScriptDefinition,
    ToolDefinition, ToolDefinitionHeader, ToolDefinitionLogicBash,
    ToolDefinitionLogicJson, ToolScriptDefinition,
)

def time_tool() -> ToolDefinition:
    return ToolDefinition(
        header=ToolDefinitionHeader(
            name="time-tool",
            description="Return current UTC time (optional timezone offset).",
            usage_explanation_long=(
                "Use for any time-related question. Pass an integer hour"
                " offset via --timezone_hours (e.g. 1=CET, -8=PST)."
            ),
            usage_explanation_short="get the current time",
            rules=["Always include a timezone in the answer."],
        ),
        bash_tool=ToolDefinitionLogicBash(
            permission_bash=BashToolPermission(
                tool_name="bash", value="allow",
                allowed_commands=["uv run scripts/time_tool.py *"],
            ),
            positive_examples=["uv run scripts/time_tool.py --timezone_hours 0"],
            negative_examples=["date"],  # never the shell builtin
            mode_specific_rules=["Always use `uv run` to launch the script."],
        ),
        json_tool=ToolDefinitionLogicJson(
            permission_json=JsonToolPermission(tool_name="custom_tool", value="allow"),
            positive_examples=["time-tool(timezone_hours=0)"],
            negative_examples=["time-tool(timezone='UTC')"],  # wrong field/type
            tool_scripts=[ToolScriptDefinition(
                paths=[HERE / "scripts" / "time_tool.py"],
                scripts=[ScriptDefinition(
                    target_file_path=Path("scripts/time_tool.py"),
                    source_file_path=HERE / "scripts" / "time_tool.py",
                    source_file_type="python", script_contents=None,
                )],
            )],
        ),
        mock=MockResponse(kind="fixed", fixed_output={
            "iso": "2026-05-17T12:00:00+00:00", "timezone_hours": 0,
        }),
        requires_resources=[],  # symbolic resource names, e.g. ["notes_db"]
    )

The compiler copies each declared script into the build tree's scripts/ (inline script_contents or a source_file_path).

4. Pick bash or JSON per agent

The agent chooses which contract to emit via default_tool_format ("bash" | "json" | "both", default "bash") plus per-tool tool_format_overrides={"time-tool": "json"}. A tool carrying only one contract always falls back to the one it has.

Symptom Use
1-3 simple args, humans read the transcripts bash
10+ args or polymorphic input shapes json
Small production model (7-13B) that fumbles structured calls bash
Model handles the custom_tool format well json

"both" doubles the prompt budget for the same capability — rarely right. examples/30_tools/ compiles one agent with both and a sibling with json so you can diff the emitted frontmatter.

5. Ship a mock with every tool

The mock=MockResponse(...) field is the tool's default testing surface. Kinds: fixed (canned fixed_output), echo (returns the validated input), callable / stateful_callable (a "module:callable" spec), and sequence (per-call responses for multi-turn scenarios). A MockProfile registered via reg.register_mock_profile(...) overrides per-tool mocks for one scenario; tests select it by name via their mock_profile field. Design rule: every tool ships a mock, so oac test runs green with zero credentials. See testing and examples/32_multi_turn_mocks/.

6. Route resources with an AccessProfile

Tools declare external systems symbolically (requires_resources=["notes_db"]); an AccessProfile binds those names per environment:

from open_agent_compiler import AccessProfile, ResourceBinding

prod = AccessProfile(name="prod", bindings={
    "notes_db": ResourceBinding(kind="postgres", config={"dsn": "..."}),
})
ci = AccessProfile(name="ci", extends="prod", bindings={
    "notes_db": ResourceBinding(kind="sqlite", config={"path": ":memory:"}),
})

Profiles compose via single-parent extends. The compile pass selects one via CompileScript(..., access_profile="ci", mock_profile="happy-path"), and the compiler validates every required resource is bound (or mock_only with a covering MockProfile) before emitting artifacts.

Bundled infrastructure scripts

The opencode dialect auto-includes three framework scripts under build/scripts/ — only when the resolved tree needs them:

Script Included when
subagent_todo.py any agent has a workflow with todo_mode != "none"
workspace_io.py any agent sets workspace=...
opencode_manager.py any agent references a mode="primary" subagent

You never declare these as tools; the compiler wires the matching prompt blocks and bash permissions automatically.

Surface the exact command verbs

Compiled agents flail — wrong --command verbs, ls + --help exploration — unless the prompt names the exact verbs a ScriptTool accepts. If your tool dispatches on a command field (init / read / write / list), derive that verb list from the field's enum (or a dispatch-table scan) and inject it verbatim into usage_explanation_long, the positive_examples, and mode_specific_rules. One concrete invocation line per verb beats any prose description; keep the examples in sync with the handler so the agent never has to discover the CLI by trial and error.