Files
fit-boilerplate-wox/docs/tool-authoring-guide.md
Felipe Arentsen b089c2ef18
Some checks failed
CI — Lint + Evals / lint (push) Has been cancelled
CI — Lint + Evals / smoke (push) Has been cancelled
feat: v1 — boilerplate WxO + web
Boilerplate completo para construir soluciones agénticas sobre IBM
watsonx Orchestrate (ADK 2.x) con una capa web encima.

Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye:

- 11 docs (best practices, architecture patterns, ADK cheatsheet,
  observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX,
  known-issues con 16 errores reales y fix, eval strategy, INDEX)
- 13 templates WxO (orchestrator/specialist/single-meta agents,
  3 connections, KB + runbook, observable_tool decorator, coercion
  helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook
  validator HMAC, MCP connection)
- 6 scripts (deploy idempotente con fallback ADK, undeploy, reset,
  check-adk-version, new-specialist scaffold, eval-agents runner)
- 4 eval pieces (linter de best practices, runner, smoke-test, direct
  backend probe) + scenario templates
- 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author,
  wxo-tool-author, runbook-author, mock-builder, backend-tool-builder,
  eval-author, web-layer-builder
- Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para
  copiar a ~/.claude/skills/
- Web layer default FastAPI+HTMX con vista timeline observable + endpoint
  receptor de trazas
- Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik,
  network split internal:true)
- CI Gitea workflow con lint + smoke

Best practices enforced por evals/lint_wxo_yaml.py:
- A1: máx 10 tools por agente
- A3: orchestrator sin tools de remediación
- A6: agente sin propósito (react sin tools ni KB)
- T1: @observable_tool obligatorio, no @tool directo
- T3: _compat shim inline en tools.py
- D1: docker-compose sin wget --spider
- I-005: OpenAPI con description per-operation
- I-009: sin labels Traefik manuales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:59:44 +00:00

8.7 KiB

Tool Authoring Guide

WxO acepta tools de tres formas. Acá te explico cuándo usar cuál y cómo.

Decisión rápida

Situación Tipo
PoC / demo / mocks Python @tool
Tu backend ya expone REST (FastAPI/Express/etc.) OpenAPI
SaaS de terceros con MCP server MCP
SaaS de terceros sin MCP OpenAPI (escribís el spec) o Python wrapper

Tipo 1 — Python @tool (estilo Cotemar)

Cuándo

  • Estás prototipando rápido
  • Los sistemas externos son mocks separados
  • La lógica del wrapper es simple
  • Querés tener cada tool en su propia función testeable

Template

wxo/tools/python/_template_tools.py:

# === inline compat shim (NO eliminar — TRM lo necesita) ===
try:
    from ibm_watsonx_orchestrate.agent_builder.tools import tool
except ImportError:
    def tool(*args, **kwargs):
        def deco(f): return f
        return deco if not args else deco(args[0])
# ============================================================

import os
import requests
from pydantic import BaseModel, field_validator

from wxo.tools.python._observable_tool import observable_tool
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list

BASE_URL = os.environ.get("BASE_URL", "")

class ResetPasswordInput(BaseModel):
    username: str
    notify: bool = True

@observable_tool(name="reset_password", domain="ad")
def reset_password(username: str, notify: bool = True) -> dict:
    """Reset AD password and return temp credentials."""
    resp = requests.post(
        f"{BASE_URL}/users/{username}/reset-password",
        json={"notify": notify},
        timeout=10
    )
    resp.raise_for_status()
    return resp.json()

Import

orchestrate tools import -k python \
  -f wxo/tools/python/ad_tools.py \
  -r wxo/tools/python/requirements.txt \
  --app-id ad_demo

Gotchas

  • No usar imports relativos (from .._compat import ...). Inlinear el shim. (Issue I-010)
  • Coerción de tipos siempre. El LLM stringifica todo. (Issue I-003)
  • BASE_URL desde env, nunca hardcoded.

Tipo 2 — OpenAPI (estilo Dun)

Cuándo

  • Tu backend ya define endpoints REST con tipos
  • Querés single-source-of-truth (el spec)
  • Tenés FastAPI/Express/etc. y solo necesitás exponer un subset a WxO

Template — endpoint público filtrado

wxo/tools/openapi/_backend_filter_endpoint.py:

from copy import deepcopy
from fastapi import FastAPI

# Allowlist de operaciones que WxO puede ver
PUBLIC_TOOLS = {
    "POST /api/v1/cases/run",
    "GET /api/v1/cases/{case_id}",
    "POST /api/v1/cases/{case_id}/approve",
}

def build_public_spec(app: FastAPI) -> dict:
    """Filtra el openapi.json de tu app a la allowlist PUBLIC_TOOLS,
    fuerza description per-operation, y resuelve $ref transitivamente."""
    full = app.openapi()
    spec = {
        "openapi": full["openapi"],
        "info": {**full["info"], "title": full["info"]["title"] + " (orchestrate)"},
        "servers": full.get("servers", [{"url": "https://CHANGEME/api/v1"}]),
        "paths": {},
        "components": {"schemas": {}},
        "security": full.get("security", []),
    }

    # Filtrar paths
    for path, item in full["paths"].items():
        for method, op in item.items():
            if method.upper() in ("GET", "POST", "PATCH", "PUT", "DELETE"):
                key = f"{method.upper()} {path}"
                if key in PUBLIC_TOOLS:
                    spec["paths"].setdefault(path, {})[method] = op
                    # description fallback (issue I-005)
                    op["description"] = op.get("description") or op.get("summary") or f"{method} {path}"
                    # security per-operation (issue I-006)
                    op["security"] = spec["security"]

    # Resolver $ref transitivamente
    needed_schemas = set()
    def walk(o):
        if isinstance(o, dict):
            for k, v in o.items():
                if k == "$ref" and isinstance(v, str) and v.startswith("#/components/schemas/"):
                    needed_schemas.add(v.split("/")[-1])
                else:
                    walk(v)
        elif isinstance(o, list):
            for x in o: walk(x)
    walk(spec["paths"])
    all_schemas = full.get("components", {}).get("schemas", {})
    # Resolver hasta closure
    pending = list(needed_schemas)
    while pending:
        s = pending.pop()
        if s not in all_schemas: continue
        spec["components"]["schemas"][s] = all_schemas[s]
        # Encontrar refs nuevos dentro
        before = set(needed_schemas)
        walk(all_schemas[s])
        for new in needed_schemas - before:
            pending.append(new)

    return spec

# Endpoint que sirve el spec filtrado
@app.get("/api/v1/orchestrate-tools-spec.json")
def public_spec():
    return build_public_spec(app)

Patch del servers[0].url en deploy time

scripts/deploy-wxo.sh baja el spec del backend live y reemplaza el server URL:

curl -s "$BACKEND_URL/api/v1/orchestrate-tools-spec.json" \
  | jq --arg url "$BACKEND_URL/api/v1" '.servers = [{"url": $url}]' \
  > /tmp/spec.json

orchestrate tools import -k openapi -f /tmp/spec.json --app-id mi_backend

Schema Pydantic con coerción

En tu backend, cada endpoint input model lleva field_validator:

from pydantic import BaseModel, field_validator
from wxo.tools.python._coercion_helpers import _coerce_int, _coerce_list

class RunCaseInput(BaseModel):
    case_id: str
    target_companyid: int
    profile_ids: list[str]
    _coerce_companyid = field_validator("target_companyid", mode="before")(_coerce_int)
    _coerce_profiles = field_validator("profile_ids", mode="before")(_coerce_list)

Observabilidad con write_audit

En cada handler:

from app.audit import write_audit

@router.post("/cases/run")
def run_case(input: RunCaseInput, x_orchestrate_token: str = Header(...)):
    write_audit(input.case_id, "tool.run_case.started", input.dict())
    result = do_the_work(input)
    write_audit(input.case_id, "tool.run_case.completed", result)
    return result

Tipo 3 — MCP

Cuándo

  • El SaaS ya expone un MCP server (HubSpot, Outline, GitHub vía MCP, etc.)
  • Querés que ADK descubra las tools automáticamente
  • Necesitás permisos del sistema externo respetados

Template

wxo/tools/mcp/_template_mcp_connection.yaml:

spec_version: v1
kind: connection
name: hubspot_mcp
display_name: "HubSpot via MCP"
schema_version: "1.0"
auth_type: mcp
identifier: hubspot_mcp_conn
preference:
  - environment: draft
    schema_id: mcp
  - environment: live
    schema_id: mcp

Setup

orchestrate connections add -a hubspot_mcp
for ENV in draft live; do
  orchestrate connections configure -a hubspot_mcp --env $ENV --type team --kind mcp
  orchestrate connections set-credentials -a hubspot_mcp --env $ENV \
    -e "MCP_SERVER_URL=https://hubspot-mcp.example.com" \
    -e "MCP_TOKEN=$HUBSPOT_TOKEN"
done

ADK descubre las tools del server y las hace disponibles al agente. En el YAML del agente listás las que necesita:

tools:
  - hubspot_mcp/get_contact
  - hubspot_mcp/create_deal

Caveats

  • La latencia depende del MCP server. Algunos son lentos.
  • Los nombres de tools vienen del MCP server, no los podés renombrar.
  • Coordiná con el equipo del MCP server qué tools va a exponer.

Híbrido

Un agente puede usar las tres formas al mismo tiempo:

tools:
  # MCP (descubiertas del server)
  - hubspot_mcp/get_contact
  - hubspot_mcp/update_deal
  # OpenAPI (importadas del backend propio)
  - run_full_case
  - get_case_status
  # Python (wrappers a mocks o sistemas pequeños)
  - lookup_internal_user

Lo único que comparten es el contrato observable: cada call emite la misma traza JSON.


Patrón "meta-tool" (issue I-001)

Si tu flujo es lineal (caso → A → B → C → D), colapsá los 4 tools en uno:

# Mal — el agente puede explotar con recursion_limit
tools:
  - get_company
  - search_credits
  - build_profile
  - package_zip
  - upload
# Bien — agente invoca 1 tool, backend orquesta el resto
tools:
  - run_full_case

El backend ejecuta los 4 pasos internamente y emite write_audit por cada uno. La UI reconstruye el timeline igual. El agente es feliz y no muere por recursion.


Checklist antes de mergear un tool nuevo

  • ¿Usa @observable_tool, no @tool directo?
  • ¿Tiene description (docstring) clara que el LLM va a leer?
  • ¿El input model tiene @field_validator(mode="before") para todo int/list?
  • ¿Lee BASE_URL del env, no hardcoded?
  • ¿Si es OpenAPI, tiene description y security per-operation?
  • ¿El agente que lo usa tiene <10 tools después de agregarlo?
  • ¿Hay un scenario de eval que cubra la happy path?
  • ¿./evals/lint_wxo_yaml.py pasa?