The AI tool-execution runtime for the Milpa PHP framework, built on
milpa/core. It runs the loop every Milpa module declares:plugin → capability → tool → verification → event → result.#[Tool]-attributed methods become a registry pipeline — resolve, validate, authorize, execute, audit — with policy gates, rate limiting, channel-aware rendering, and human/agent verification as first-class seams.
milpa/tool-runtime is where milpa/core's agent-tool-readiness seam becomes a working
engine. Milpa\Interfaces\Tooling\ToolProviderInterface and ToolRegistryInterface are
contracts defined in core; this package is the concrete ToolRegistry that resolves,
validates, authorizes, executes, and audits every call, plus the #[Tool] attribute that
lets a plain PHP method declare itself as agent-callable. No Doctrine, no HTTP kernel, no
concrete policy storage — those live in your host application.
composer require milpa/tool-runtimeAttribute a method with #[Tool]; parameters describe themselves with #[Param]:
use Milpa\ToolRuntime\Attributes\Param;
use Milpa\ToolRuntime\Attributes\Tool;
use Milpa\ToolRuntime\ToolResult;
final class NoteTools
{
#[Tool('list_notes', 'List saved notes', scopes: ['notes:read'])]
public function listNotes(
#[Param('Page number', clamp: [1, 1000])] int $page = 1
): ToolResult {
return ToolResult::success(['notes' => [], 'page' => $page]);
}
}ToolScanner reflects the class for #[Tool] methods and registers them; ToolRegistry
runs the full pipeline on every call:
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\ToolRegistry;
use Milpa\ToolRuntime\ToolScanner;
use Psr\Log\NullLogger;
$registry = new ToolRegistry(new NullLogger());
(new ToolScanner($registry))->scan(new NoteTools());
$result = $registry->call('list_notes', ['page' => 1], ToolContext::cli());
$result->success; // true
$result->data; // ['notes' => [], 'page' => 1]
$result->toJson(); // {"success":true,"data":{...},"message":null,"error":null,"meta":{...}}No ToolContext is required — call() defaults to ToolContext::cli() (full-access,
for scripts and tests). Real hosts build one per channel: ToolContext::mcp($requestId, $principal, $scopes), ToolContext::telegram($chatId, $userId), or a custom new ToolContext(...) for a web session.
Every ToolRegistry::call() runs the same six steps, in order, regardless of who is
calling — a human over cli, an LLM over mcp, or a bot over telegram:
- Resolve — look up the tool by name; an unknown name is a typed
ToolResult::error()(ToolResult::TOOL_NOT_FOUND), never an exception. - Validate —
SchemaValidatorchecks the arguments against the tool's JSON input schema (required fields, types), then applies numericclampsbefore execution. - Authorize —
PolicyGatechecks the caller'sToolContextscopes against the tool's required scopes, then falls back to per-channel policy (cliallows all,mcpandwebrequire auth by default). A host can plug inPolicyRuleProviderInterfacefor database-backed rules, and an optionalRateLimiterInterfacethrottles bychannel:principal:tool. - Confirm (mutating tools only) — a tool declared
confirm: true(or matching a channel'srequire_confirmation_for_mutatingpolicy) returns aconfirm_tokenon the first call instead of executing; the caller replays the same arguments plus that token to proceed.ConfirmationTokenStoreholds the pending action and its expiry. - Execute — the tool's callback runs with a soft timeout; a bare return value is
wrapped in
ToolResult::success()automatically, and an uncaughtThrowablebecomesToolResult::error()(ToolResult::INTERNAL_ERROR) instead of propagating. - Audit —
ToolAuditLoggerrecords every call (success, failure, or rejection) via PSR-3, redacting sensitive argument fields (password,token,secret, …) before they ever reach a log line.
A ToolContext built with mode: 'plan' (or ToolContext::asPlan()) short-circuits after
step 3: it validates and authorizes but never executes, returning the would-be plan instead
— a dry-run for any tool, for free.
Some actions can't be authorized by scopes alone — they need a human or another agent to
say yes. milpa/core defines the seam: Milpa\Interfaces\Verification\VerifierInterface,
whose verify() returns a VerificationResult that may be PENDING and resolve later.
This package ships the reference implementation:
HumanVerifierimplementsVerifierInterface.verify()cannot decide synchronously, so it returnsVerificationResult::pending()and dispatchesverification.requested; a latergrant()/reject()call resolves it and dispatchesverification.granted/verification.rejected.HumanVerifyToolexposesHumanVerifieras thehuman_verifytool — the same registry pipeline every other tool runs through, no special-cased transport. Itsregister()marks itToolOptions(mutating: true, requiresConfirmation: true), so any call throughToolRegistry::call()— whether it's a request or a resolve — hits the registry's own step-4 confirmation gate (see The pipeline) beforeHumanVerifyTool::handle()ever runs.
Calling human_verify via $registry->call() behaves exactly like any other
requiresConfirmation tool: the first call does not run handle(). It mints a
confirm_token and returns the registry's generic wrapper instead — no request_id
anywhere, because this wrapper is generic and knows nothing about HumanVerifier:
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\HumanVerifyTool;
(new HumanVerifyTool(new HumanVerifier()))->register($registry);
$registry->call('human_verify', [
'subject' => 'gate:report.publish',
'decision' => 'grant',
'principal' => 'agent:claude',
'request_id' => 'req-123',
], $ctx);
// -> ToolResult success, data: [
// 'requires_confirmation' => true,
// 'confirm_token' => '7e01bf71...',
// 'action_summary' => 'human_verify(subject=gate:report.publish, decision=grant, principal=agent:claude)',
// 'expires_at' => '2026-07-07T02:16:26+00:00',
// ]
// handle() has NOT run — HumanVerifier::grant() has not been called yet.
$registry->call('human_verify', [
'subject' => 'gate:report.publish',
'decision' => 'grant',
'principal' => 'agent:claude',
'request_id' => 'req-123',
'confirm_token' => '7e01bf71...', // from the previous response
], $ctx);
// -> ToolResult success, data: [
// 'status' => 'passed', 'reason' => null, 'verifier' => 'human_verify',
// 'principal' => 'agent:claude', 'missing' => [], 'metadata' => [],
// ]
// NOW handle() ran, using the exact args ConfirmationTokenStore stored at create() time
// (not whatever you pass on the second call) — this is what actually invoked HumanVerifier::grant().The second call must replay the same arguments as the first, plus confirm_token;
ConfirmationTokenStore::consume() hands ToolRegistry::call() back the args it stored,
and those — not the ones on the redeeming call — are what handle() receives.
human_verify through the registry without a decision (to open a request)
hits the same gate: the first call only returns a confirm_token, and redeeming it invokes
handle() with no decision, which — being HumanVerifyTool's own request phase — returns
another confirmation, this one carrying request_id. Resolving that request then needs a
second confirm-token round trip (this time with decision in the args) — four registry
calls end-to-end. For the two-phase request_id flow HumanVerifyTool was built around,
skip the registry and call the tool directly — see below.
HumanVerifyTool's request → resolve round trip (open a request, get a request_id,
resolve it later with that id) is reached by calling handle() directly — this is how the
package's own tests exercise it (tests/Unit/Verification/HumanVerifyToolTest.php), and
it's the sanctioned way to drive D8 verification programmatically, independent of the
registry's confirmation gate:
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\HumanVerifyTool;
$tool = new HumanVerifyTool(new HumanVerifier($eventDispatcher));
$request = $tool->handle(['subject' => 'gate:report.publish']);
// -> ToolResult::confirmation(), $request->data['request_id'] === '06a1dda5-...'
// HumanVerifier::verify() ran and dispatched `verification.requested`.
$tool->handle([
'subject' => 'gate:report.publish',
'decision' => 'grant',
'principal' => 'agent:claude',
'request_id' => $request->data['request_id'],
]);
// -> ToolResult::success(), data: ['status' => 'passed', 'principal' => 'agent:claude', ...]
// HumanVerifier::grant() ran and dispatched `verification.granted`.Any other VerifierInterface implementation — a deterministic rule, a quorum vote, an
external approval service — plugs into the same seam.
| Layer | Package | Owns |
|---|---|---|
| Contracts | milpa/core |
ToolProviderInterface, ToolRegistryInterface, VerifierInterface, capability/verification value objects and events — the seams, not the engine. |
| Runtime | milpa/tool-runtime (this package) |
The concrete ToolRegistry pipeline, #[Tool]/#[Param] attributes + ToolScanner, SchemaValidator, PolicyGate, rate limiting, channel rendering, ToolAuditLogger, and the human_verify reference verifier. |
| Your app | your host / plugins | Concrete PolicyRuleProviderInterface (e.g. Doctrine-backed rules), LoggerInterface, channel renderers, and where policy decisions and audit logs are actually persisted. |
The types you construct and pass around day to day:
| Type | What it is |
|---|---|
Contracts\ToolContext |
Who/where/what-scopes for one call — principal, channel, scopes, mode. Named constructors per channel: cli(), mcp(), telegram(). |
ToolResult |
The uniform return shape — success, data, message, error, meta. Factories for common shapes: success(), error(), paginated(), detail(), confirmation(), blocked(). |
ToolRegistry |
The pipeline: register() to add a tool by hand, call() to run resolve→validate→authorize→execute→audit, getTools() / getToolsWithinBudget() for LLM/MCP exposure. |
Rendering\RendererRegistry |
Picks a ChannelRendererInterface for a ToolResult based on ToolContext::$channel, falling back to a default renderer or raw JSON. |
Contracts\LlmServiceInterface |
The seam a plugin implements to provide LLM access (generateResponse()) and other plugins consume to get one, without depending on a specific provider. |
- PHP ≥ 8.3
milpa/core^0.2psr/log^3
Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.
Apache-2.0 © the Milpa authors.
Milpa is designed, built, and maintained by TeamX Agency.