Skip to content

Expose use cases as AI tools

Application tools give an AI runtime or another machine caller a stable name, validated input and output, declared errors, and safety metadata for an existing use case. The tool boundary does not choose a model provider, agent loop, or transport.

A ToolRunner owns one application lifespan and scoped context per call. It validates input before opening either resource and validates the result before the context commits.

Declare and bind a tool

Keep the declaration and its binding with the feature:

# app/features/projects/tools.py
from app.shared.errors import unauthorized
from tenchi.tools import tool, tool_group, tool_handler

from .schemas import Project
from .use_cases.list_projects import list_projects


search_projects_tool = tool(
    "projects.search",
    result=list[Project],
    description="List projects owned by the authenticated user.",
    errors=(unauthorized,),
    read_only=True,
    open_world=False,
)

tools = tool_group(
    tool_handler(search_projects_tool, list_projects),
)

The use case remains an ordinary async function:

async def list_projects(context: AppContext) -> list[Project]:
    owner = require_owner_scope(context.user)
    return await context.projects.list_owned_by(owner)

tool_handler() checks the function when the module imports. A tool with a request requires exactly annotated request and context parameters; a tool without a request requires only context. The return annotation must exactly match the declared result.

The same use case may also be bound to an HTTP route. Authentication and presentation remain entrypoint concerns while authorization stays inside the shared use case.

Compose an authenticated runner

Combine feature groups at server composition, after the caller's identity has been authenticated:

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import replace

from app.features.projects.tools import tools as project_tools
from app.server.context import AppContext
from app.server.runtime import create_context, create_lifespan
from app.shared.users import User
from tenchi.tools import ToolRunner, create_tool_runner, tool_group

tools = tool_group(project_tools)


def create_user_tool_runner(
    *,
    database_path: str,
    user: User,
) -> ToolRunner:
    @asynccontextmanager
    async def tool_context(state: str) -> AsyncGenerator[AppContext]:
        async with create_context(state) as context:
            yield replace(context, user=user)

    return create_tool_runner(
        tools=tools,
        context_factory=tool_context,
        lifespan=create_lifespan(database_path),
    )

The identity is application-owned context, not model-supplied tool input. Use cases still call require_user() and pure policies because the same behavior may run through HTTP, a job, a test, or another future entrypoint.

Safety annotations do not authorize calls

read_only, destructive, idempotent, and open_world describe expected behavior to a trusted adapter or client. They never replace authentication, application policies, idempotency, quotas, or an approval decision.

Call a tool

Pass Python data or raw JSON:

result = await runner.call(
    "projects.search",
)

moved = await runner.call(
    "tasks.move",
    input_json="""{
      "task_id": "task_123",
      "status": "done",
      "expected_version": 4,
      "idempotency_key": "agent-run_456"
    }""",
)

Input validation happens before lifespan or context acquisition. Output validation happens inside the context scope, so a result-contract violation can roll back transactional writes. Cancellation passes through the use case and both cleanup scopes.

Pass use_case_observers= to create_tool_runner() to receive the normal payload-safe UseCaseOutcome with entrypoint="tool".

Declare caller-visible errors

List every ErrorDef that a caller may receive:

move_task_tool = tool(
    "tasks.move",
    request=MoveTaskInput,
    result=Task,
    description="Move one visible task to another workflow status.",
    errors=(
        unauthorized,
        task_not_found,
        forbidden,
        precondition_failed,
        IDEMPOTENCY_CONFLICT,
        IDEMPOTENCY_IN_PROGRESS,
        RATE_LIMITED,
    ),
    destructive=True,
    idempotent=True,
    open_world=False,
)
FailureRunner behavior
Invalid inputRaises Pydantic ValidationError before resources open
Unknown nameRaises ToolNotFoundError
Declared AppErrorPreserves the declared application error
Undeclared AppErrorRaises generic ToolInvocationError with the original error as its cause
Unexpected exceptionRaises the same generic ToolInvocationError
Invalid resultRaises ToolResultError before context commit
CancellationPropagates cancellation through cleanup

The generic invocation error contains the tool name but no exception message, details, request, or result.

Inspect the portable manifest

Use tool_manifest() when an adapter or a test needs deterministic discovery:

from tenchi.tools import tool_manifest

manifest = tool_manifest(tools)

The versioned manifest sorts tools and errors by stable name. Each entry contains the description, input and output JSON Schemas, declared error codes and messages, and all four safety annotations. A no-input tool receives an empty object input schema; callers may either omit input or pass {}.

tool() validates, converts to JSON, and retains both schemas when the declaration is built. Invalid custom schemas therefore fail during composition, and repeated manifest calls return the same schema.

TOOL_MANIFEST_VERSION identifies the portable manifest contract. Tenchi uses a new version for breaking changes rather than silently changing an adapter's assumptions.

The manifest describes registered application tools only. It does not expose Python functions, context values, credentials, request payloads, or results.

Protect the contract in version control

Write the composed manifest from the application root:

uv run tenchi tools --write tools.json

tenchi check compares the generated manifest with tools.json so an unrecorded contract change fails locally and in CI. Before accepting a changed snapshot, classify it against the current file:

uv run tenchi tools --diff tools.json
uv run tenchi tools --write tools.json

For pull requests, compare with a historical baseline even when the branch also updates tools.json:

uv run tenchi tools \
  --diff-ref origin/main \
  --snapshot tools.json

The compatibility report treats these changes directionally:

Breaking and unknown changes exit non-zero. Use --diff-format json when an agent or CI system needs the versioned report. tenchi tools --json returns the current registered manifest in a versioned result without writing a file.

tenchi map includes tool nodes, their feature ownership, safety details, registration state, and exact bindings to use cases. This lets a coding agent find both the contract and the behavior it exposes before editing either one.

Connect a transport

Use the manifest for discovery and call ToolRunner.call() from the SDK's tool callback. The adapter remains responsible for:

For MCP clients, create_tool_mcp_server() maps schemas and failures, propagates cancellation, and invokes callbacks for authentication, visibility, and approval policy.

tenchi mcp is a different server: it lets a coding agent inspect and validate a Tenchi repository. It does not expose the application's ToolGroup.