This document is intended for tool implementers and explains how to integrate with the UTP tool flow and quickly implement a tool executor based on OpenAPI.
- Tool execution endpoints must not write to
state.*. - Tool parameters must be read from the
tool.callcard via thetool_call_card_idpointer; do not readargs/argumentsdirectly from the payload (the current backend rejects this by protocol). - Control/routing identity (
agent_id/agent_turn_id/turn_epoch/tool_call_id/step_id/...) must come fromCG-*headers /CGContext, not payload. - Callback payload should only contain business/result fields (for example:
status/after_execution/tool_result_card_id). - To override a valid
after_execution, writesuspend|terminatetotool.result.content.result.__cg_control.after_execution(preserving the namespace); callback fields still need to be carried through unchanged (includingafter_executionfrom both command and writeback). - Callback tracing must create a child span using the inbound
traceparentas the parent, and inject a newtraceparent(tracestateis optional). tool.resultmetadata.trace_id/parent_step_id/step_idmust be copied verbatim from thetool.callcard metadata (no payload-based backfilling).- Content structure requirements:
tool.callcontent must beToolCallContent, andtool.resultcontent must beToolResultContent; arbitrary dicts or text are no longer accepted as content. - For
cmd.tool.*,CG-Tool-Call-Idis a required control header.ToolCommandPayloadcarries business fields such astool_call_card_id/tool_name/after_execution. publish_tool_result_reportnow validates the result card pointer plus theauthor_id/function_name/source_ctxcombination. A callback that only “knows the correlation id” is not sufficient.
The authoritative protocol is: 04_protocol_l0/nats_protocol.md.
- Subscribe to the
cmd.tool.*subject (with{project_id}/{channel_id}expanded) from the tool'starget_subjectinresource.tools. - Parse the command and validate required
CG-*control headers (especiallyCG-Tool-Call-Id) plus payload business fields (tool_call_card_id, usually alsotool_name/after_execution). - Read the
tool.callcard (tool_call_card_id) and fetch parameters fromToolCallContent.arguments. - Execute business logic.
- Construct a
tool.resultcard and callback payload (business-only fields, usuallyafter_execution/status/tool_result_card_id). - Call
publish_tool_result_reportto writetool_resultto the L0 Inbox; L0 will triggercmd.agent.{target}.wakeupto the targetworker_targetof the target agent byagent_id.
Note: The callback wakeup target is not fixed to worker_generic; it must be determined by the callee agent's current worker_target in roster.
import json
from core.subject import parse_subject
from core.utp_protocol import ToolCallContent
from core.utils import safe_str
from infra.l0.tool_reports import publish_tool_result_report
from infra.messaging.ingress import build_nats_ingress_context
from infra.tool_executor import ToolResultBuilder, ToolResultContext
async def handle_tool_command(msg, *, cardbox, nats, execution_store, resource_store=None, state_store=None):
raw = json.loads(msg.data)
parts = parse_subject(msg.subject)
if parts is None:
raise RuntimeError("invalid subject")
ingress_ctx, cmd_data = build_nats_ingress_context(
dict(msg.headers or {}),
raw,
parts,
)
tool_call_card_id = str(cmd_data["tool_call_card_id"])
cards = await cardbox.get_cards([tool_call_card_id], project_id=ingress_ctx.project_id)
if not cards:
raise RuntimeError(f"tool_call_card not found: {tool_call_card_id}")
tool_call_card = cards[0]
args = {}
if isinstance(tool_call_card.content, ToolCallContent):
args = dict(tool_call_card.content.arguments or {})
tool_call_meta = getattr(tool_call_card, "metadata", {}) or {}
try:
result = do_something(args)
status = "success"
except Exception as exc:
result = {
"error_code": "internal_error",
"error_message": str(exc),
"error": {"code": "internal_error", "message": str(exc)},
}
status = "failed"
ctx = ToolResultContext.from_cmd_data(
ctx=ingress_ctx,
cmd_data=cmd_data,
tool_call_meta=tool_call_meta,
)
payload, result_card = ToolResultBuilder(
ctx,
author_id="tool.example",
function_name=safe_str(cmd_data.get("tool_name")) or "unknown",
).build(
status=status,
result=result,
)
await cardbox.save_card(result_card)
source_ctx = ingress_ctx.evolve(
agent_id="tool.example",
agent_turn_id="",
step_id=None,
tool_call_id=None,
)
await publish_tool_result_report(
nats=nats,
execution_store=execution_store,
cardbox=cardbox,
resource_store=resource_store,
state_store=state_store,
source_ctx=source_ctx,
target_ctx=ingress_ctx,
payload=payload,
)
return {
"tool_call_id": ingress_ctx.require_tool_call_id,
"agent_turn_id": ingress_ctx.require_agent_turn_id,
"turn_epoch": ingress_ctx.turn_epoch,
"status": status,
}- Prefer the
CGErrorsystem fromcore/errors.pyand generate usingbuild_error_result_from_exception:result.error_code/result.error_messageerror.code/error.message/error.detail- This allows UI/Worker/PMO to parse errors and status consistently.
- Prefer uploading definitions via management API:
POST /projects/{project_id}/tools(YAML)- Fields:
tool_name / target_subject / after_execution / parameters / options / description
ToolServiceconstraints for external tools:after_executioncan only besuspendorterminatetarget_subjectmust becmd.tool.*(notcmd.sys.*)- Tool name cannot be a PMO internal tool name (
delegate_async/launch_principal/ask_expert/fork_join/provision_agent)
- Tool definitions are persisted in
resource.toolswith these fields:project_id, tool_name, parameters, target_subject, after_execution, options. - The worker side resolves definitions by
target_subjectand writesafter_execution + tool_call_card_id + paramsinto thecmd.tool.*command; when Tool Service writes back, it injects trace/step lineage from thetool.callcard metadata.
services/tools/openapi_service.py has migrated to the Jina API:
- Search docs:
https://s.jina.ai/docs - Reader docs:
https://r.jina.ai/docs
Recommend using the jina namespace in resource.tools.options:
{
"args": {
"defaults": {"session_id": "{agent_turn_id}"},
"fixed": {"project_id": "{project_id}"}
},
"envs": {"api_key": "SEARCH_API_KEY"},
"jina": {
"mode": "search",
"base_url": "https://s.jina.ai",
"path": "/search",
"method": "GET",
"auth_env": "JINA_API_KEY"
}
}Recommendations:
mode=searchusess.jina.ai;mode=readerusesr.jina.ai.- Authentication keys should only be read from environment variables (default
JINA_API_KEY), and must never be persisted to cards/logs. openapi_serviceonly allows access to the two domainss.jina.ai/r.jina.ai.
options.args.defaults: inject only when LLM does not provide the parameter; explicitnullis treated as provided.options.args.fixed: forcibly overwrite and do not expose to LLM.options.envs: parameter name -> environment variable name; only the variable name is recorded in cards, while actual value is injected by the executor at runtime.- Tool executors can override
envswith values from service configuration (service-side configuration takes priority).
Template variables (temporarily supporting only ID fields): {project_id} / {channel_id} / {agent_id} / {agent_turn_id} /
{step_id} / {tool_call_id} / {parent_step_id} / {trace_id} / {turn_epoch} / {context_box_id}.
For tools that accept session_id, treat it as a caller-visible alias only. The runtime may scope the real execution identity by owner (project_id + agent_id) internally, so the same alias is not a cross-agent sharing contract.