PHPackages                             milpa/tool-runtime - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. milpa/tool-runtime

ActiveLibrary[Framework](/categories/framework)

milpa/tool-runtime
==================

AI tool-execution runtime for the Milpa PHP framework: registry pipeline (resolve, validate, authorize, execute, audit), #\[Tool\] attributes, schema validation, policy gate, rate limiting, channel rendering, and human verification.

v0.5.1(2d ago)0181↓49%3Apache-2.0PHP &gt;=8.3

Since Jul 7Compare

[ Source](https://github.com/getmilpa/tool-runtime)[ Packagist](https://packagist.org/packages/milpa/tool-runtime)[ RSS](/packages/milpa-tool-runtime/feed)WikiDiscussions Synced today

READMEChangelog (7)Dependencies (7)Versions (8)Used By (3)

 [   ![Milpa](https://raw.githubusercontent.com/getmilpa/core/main/art/lockup/milpa-lockup-v-color-light.svg)  ](https://github.com/getmilpa)

Milpa ToolRuntime
=================

[](#milpa-toolruntime)

> 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.

[![CI](https://github.com/getmilpa/tool-runtime/actions/workflows/ci.yml/badge.svg)](https://github.com/getmilpa/tool-runtime/actions/workflows/ci.yml)[![Packagist](https://camo.githubusercontent.com/4893220aa1870a40e2e6b225f453b396cb1c6db6eb5a98cd9039baaa415efa64/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d696c70612f746f6f6c2d72756e74696d652e737667)](https://packagist.org/packages/milpa/tool-runtime)[![PHP](https://camo.githubusercontent.com/ca03f11ea27dac4dedc8ad56a7bdfc4a9ff5feb825055f9d2983616115076607/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135253230382e332d3737376262342e737667)](https://www.php.net/)[![License](https://camo.githubusercontent.com/798509b4df525f56802b56f8096862487f08023e3d7561c68656f8dab10d0d6e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4170616368652d2d322e302d626c75652e737667)](LICENSE)[![Docs](https://camo.githubusercontent.com/c6dc6a3411e15b0ac7cc4583e8e6a8144181caedb82f5d98753353decda06d77/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f646f63732d4150492532307265666572656e63652d626c75652e737667)](https://getmilpa.github.io/tool-runtime/)

`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.

Install
-------

[](#install)

```
composer require milpa/tool-runtime
```

Quick example
-------------

[](#quick-example)

Attribute 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)` for an authenticated MCP caller, `ToolContext::stdio($requestId)` for a trusted local stdio MCP server process (no per-caller auth — see [Authorize](#the-pipeline) below), `ToolContext::telegram($chatId, $userId)`, or a custom `new ToolContext(...)` for a web session.

### Object-shaped parameters

[](#object-shaped-parameters)

A PHP `array` has no native type that maps to JSON-Schema `type: object` on its own — without an override, `ToolScanner` infers `array` -&gt; schema `type: array`, and `SchemaValidator` then requires that value to be a JSON *list*, rejecting an associative payload like `{"post_id": 1}` outright. Pass `type: 'object'` on `#[Param]` to opt a PHP `array $param` into `type: object` instead; add `properties`/`requiredProperties` to declare its shape (both optional — omit them for an open object with no declared shape):

```
#[Tool('update_post', 'Update fields on a post')]
public function updatePost(
    int $post_id,
    #[Param('Fields to update', type: 'object', properties: [
        'title' => ['type' => 'string'],
        'body' => ['type' => 'string'],
    ])]
    array $updates
): ToolResult {
    // $updates arrives as a plain associative array — ['title' => ..., 'body' => ...] —
    // no manual json_decode() needed; the host's JSON transport already decoded it that way.
    return ToolResult::success(['post_id' => $post_id, 'updates' => $updates]);
}
```

This is purely opt-in (tool-runtime 0.6): a bare `array $param` with no `#[Param(type: ...)]`override keeps generating `type: array` and keeps requiring a list, exactly as before.

### Parameter names on the wire

[](#parameter-names-on-the-wire)

`ToolScanner` takes each wire argument name straight from `ReflectionParameter::getName()` — there is no snake\_case conversion. A PHP parameter `string $instanceId` produces the schema property `instanceId`, not `instance_id`; a caller sending `instance_id` gets a "missing required field" error. Every single-word parameter is unaffected (there is nothing to convert), but a multi-word wire name must be matched by naming the PHP parameter itself in that exact case — e.g. `string $instance_id` for a `snake_case`-conventioned tool family, or `string $instanceId` for a `camelCase` one. Pick the PHP parameter name to match your tool family's own wire convention; `#[Param]`'s `description`/`type`/etc. do not rename the property.

The pipeline
------------

[](#the-pipeline)

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`:

1. **Resolve** — look up the tool by name; an unknown name is a typed `ToolResult::error()`(`ToolResult::TOOL_NOT_FOUND`), never an exception.
2. **Validate** — `SchemaValidator` checks the arguments against the tool's JSON input schema (required fields, types), then applies numeric `clamps` before execution.
3. **Authorize** — `PolicyGate` checks the caller's `ToolContext` scopes against the tool's required scopes, then falls back to per-channel policy (`cli` allows all, `mcp` and `web`require auth by default). A host can plug in `PolicyRuleProviderInterface` for database-backed rules, and an optional `RateLimiterInterface` throttles by `channel:principal:tool`.
4. **Confirm** *(mutating tools only)* — a tool declared `confirm: true` (or matching a channel's `require_confirmation_for_mutating` policy) returns a `confirm_token` on the first call instead of executing; the caller replays the same arguments plus that token to proceed. `ConfirmationTokenStore` holds the pending action and its expiry.

    **The redemption contract, precisely:** on the first call, `ConfirmationTokenStore::create()`snapshots the *exact args of that call* (name + args + a 60s-default expiry) and hands back a random token. The caller is expected to replay the same arguments plus `confirm_token` on the second call — but the runtime does not diff or validate that replay: `ToolRegistry::call()`strips `confirm_token` off the incoming args, calls `ConfirmationTokenStore::consume($token, $name)`, and — if the token is valid, unexpired, and minted for this tool name — **discards whatever args the second call actually sent** and executes with the args stored at `create()`time instead. A token is one-time-use (deleted on consume) and matched only by `$name`, not by argument identity. Practically: the second call's args (other than `confirm_token` itself) are inert — the tool executes with the *first* call's arguments, not the second's.
5. **Execute** — the tool's callback runs with a soft timeout; a bare return value is wrapped in `ToolResult::success()` automatically, and an uncaught `Throwable` becomes `ToolResult::error()` (`ToolResult::INTERNAL_ERROR`) instead of propagating.
6. **Audit** — `ToolAuditLogger` records every call (success, failure, or rejection) via PSR-3, redacting sensitive argument fields (`password`, `token`, `secret`, …) before they ever reach a log line. Since 0.5, the success/failure legs of this are event-driven — see [Events](#events-toolexecuting--toolexecuted--toolfailed) below.

Since 0.5, one more thing happens between step 4 and step 5: if a `MilpaEventDispatcherInterface`is wired, `ToolRegistry` dispatches `tool.executing` — a listener may short-circuit the call (answer on the tool's behalf, e.g. a cache) or veto it outright, both without step 5 ever running. See [Events](#events-toolexecuting--toolexecuted--toolfailed).

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.

**Denials say which check failed.** When step 3 denies a call, `ToolResult::error()`'s message names the specific check and what was missing — not a bare "forbidden". A channel that requires auth reports `"channel 'mcp' requires an authenticated principal (require_auth) — none provided."`; a scope mismatch reports `"Missing required scope for tool 'resolve_verification'. Need one of: verification:resolve — context has: tasks:write."`; a `block_mutating` channel policy names the tool and the channel; a `PolicyRuleProviderInterface`denial names the rule id, the tool, and the channel; a rate-limit denial names the exact `channel:principal:tool` key that hit its budget. The error **code** stays `ToolResult::FORBIDDEN` (or `ToolResult::RATE_LIMITED`) either way — callers that match on the code are unaffected; only the message got specific enough to debug from the error alone.

**Trusted local stdio MCP servers**: a no-auth `mcp` transport (an editor or agent runtime spawning your server as a child process, with no separate per-caller identity to authenticate) should build its `ToolContext` with `ToolContext::stdio($requestId)` — it hard-codes `principal: 'stdio'` and the wildcard `['*']` scope, the same "process boundary IS the trust boundary" shape `ToolContext::cli()` already uses for CLI scripts. This exists because the `mcp` channel's built-in policy sets `require_auth: true`: a bare `new ToolContext(channel: 'mcp')` (no `principal`) hits exactly the denial described above, one call at a time, with no documented way out before this factory existed.

Events: `tool.executing` / `tool.executed` / `tool.failed`
----------------------------------------------------------

[](#events-toolexecuting--toolexecuted--toolfailed)

Since 0.5 (the event-driven retrofit), `ToolRegistry` accepts an optional `Milpa\Interfaces\Event\MilpaEventDispatcherInterface` as its second constructor argument — the same nullable-dispatcher-in-the-ctor pattern `HumanVerifier` already uses below. Without one, `call()` behaves exactly as it did before 0.5: there is no interception point, and `ToolAuditLogger` still logs every call (see below) — "no dispatcher" never means "no audit trail", it only means "nothing can intercept."

With a dispatcher wired, three events fire around every call:

EventWhenCarriesSlot?`tool.executing`**PRE** — after resolve, validate/clamp, `PolicyGate::authorize()`, rate-limiting, and the confirm-gate have **all already run and passed**`Events\ToolExecutingEvent($name, $ctx, $args)`**Yes** — a `Milpa\Events\InterceptionSlot` travels alongside it`tool.executed`**POST** — a call finished, live or via a cache short-circuit`Events\ToolExecutedEvent($name, $ctx, $args, $result, $cacheServed)`No — readonly notification`tool.failed`**POST** — the tool's own callback threw`Events\ToolFailedEvent($name, $ctx, $args, $exception, $tookMs)`No — readonly notification### The security anchor

[](#the-security-anchor)

`tool.executing` is dispatched from exactly one place inside `ToolRegistry::call()`: *after*every gate — resolve → validate/clamp → authorize → rate-limit → confirm — has already run and said yes, and *before* `call_user_func()` ever invokes the tool's callback. This ordering is load-bearing, not incidental: a `tool.executing` listener (a cache plugin, say) only gets a turn once authorization has already cleared the call. A denied or rate-limited call returns before `tool.executing` is ever dispatched — the listener is never invoked for it, full stop. Moving this dispatch any earlier (e.g. "before validate" or "before authorize") would let a cache hit stand in for an authorization check that never ran — an auth bypass wearing a cache's clothes.

### The cache-plugin recipe

[](#the-cache-plugin-recipe)

A listener on `tool.executing` can answer on the tool's behalf via the `InterceptionSlot`dispatched alongside the event — the tool's real callback never runs:

```
use Milpa\Events\InterceptionSlot;
use Milpa\ToolRuntime\Events\ToolExecutingEvent;
use Milpa\ToolRuntime\ToolResult;

$dispatcher->subscribe('tool.executing', function (string $eventName, array $payload): void {
    /** @var ToolExecutingEvent $event */
    $event = $payload['event'];
    /** @var InterceptionSlot $slot */
    $slot = $payload['slot'];

    $cached = $myCache->get($event->name, $event->args);
    if ($cached !== null) {
        // Short-circuit: the real callback never runs; ToolRegistry::call() returns this
        // result instead. tool.executed STILL fires afterwards, marked cacheServed: true —
        // a cache hit is never invisible to audit/metrics listeners.
        $slot->shortCircuit(ToolResult::success($cached));
    }
});

$registry = new ToolRegistry($logger, $dispatcher);
```

Because the anchor sits strictly after `authorize()`, this cache plugin can **never** answer a call `PolicyGate` already denied — the security property, verified end-to-end (including a real `EventDispatcher`, a real denied principal, and an assertion that the cache listener's invocation count stays at zero) in `tests/Events/CacheShortCircuitTest.php`.

A listener may instead call `$slot->stop()` (without `shortCircuit()`) for a pure veto — `call()`then returns `ToolResult::blocked('Tool execution vetoed by an event listener')` without ever invoking the callback, and without a replacement result.

### `ToolAuditLogger` is a listener, not an imperative call

[](#toolauditlogger-is-a-listener-not-an-imperative-call)

`ToolRegistry`'s constructor subscribes its internal `ToolAuditLogger` to `tool.executed` / `tool.failed` whenever a dispatcher is supplied — `ToolAuditLogger::onToolExecuted()` / `onToolFailed()` reproduce the exact log lines the registry used to emit imperatively (including the soft-timeout warning, now driven off `$result->meta['timeout_exceeded']` instead of a direct call). When **no** dispatcher is wired, `call()` invokes those same listener methods directly instead of going through `dispatch()` — so "no dispatcher" still means "full audit trail", never "silently stopped logging."

Validation failures, authorization denials, and rate-limit rejections keep logging exactly as they did before 0.5 (`logValidationFailure()` / `logAuthFailure()` / a direct `log()` call in the rate-limit branch) — those all happen *before* the security anchor, so there is no `tool.*` event yet dispatched for them to hang off.

Verification: `request_verification` / `resolve_verification`
-------------------------------------------------------------

[](#verification-request_verification--resolve_verification)

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:

- **`HumanVerifier`** implements `VerifierInterface`. `verify()` cannot decide synchronously, so it returns `VerificationResult::pending()` and dispatches `verification.requested`; a later `grant()` / `reject()` call resolves it and dispatches `verification.granted` / `verification.rejected`.
- **`VerificationTool`** exposes `HumanVerifier` as **two** tools — the *same* registry pipeline every other tool runs through, no special-cased transport:

    - `request_verification(subject, policy?, requested_by?, request_id?)` opens a verification and returns its `request_id`. Its schema has **no** `decision` or `principal` field at all — this tool can never grant or reject anything, only open a request.
    - `resolve_verification(request_id, decision, principal, subject?, reason?)` resolves a pending one. `request_id`, `decision` (`grant`|`reject`), and `principal` are **required**; `subject` and `reason` are optional. When `subject` is omitted, the resolved `VerificationRequest` is built via `VerificationRequest::forResolution()`(`subject` stays `null`, identified purely by `request_id`) — the earlier `subject = $request_id` fallback (tool-runtime 0.3) is gone, so a `verification.granted` / `verification.rejected` listener never sees a fabricated subject. The tool's own success *message* still shows `request_id` in place of `subject` for readability; that is display-only formatting, not the `VerificationRequest`'s `subject` field.

    Tool-runtime 0.2 shipped this as a single combined tool that mixed both phases behind one schema, distinguished only by whether `decision` was present — a shape whose name also invited reading it as "the caller can verify itself". 0.3 splits it into the two tools above; the old combined tool no longer exists.

    Both tools register with `ToolOptions(mutating: true, requiresConfirmation: false)` — the registry's generic step-4 confirmation gate (see [The pipeline](#the-pipeline)) is deliberately **bypassed** for both, because `handleRequest()` / `handleResolve()` together already *are* the two-phase confirmation protocol (open a request, resolve it later). Stacking the registry's confirm-token gate on top of that would recreate the confusing 3-4 call choreography tool-runtime 0.2 already killed for the combined tool — see [Changed in 0.2: the double-gate bypass](#changed-in-02-the-double-gate-bypass) for that history.

    ⚠️ The bypass is not absolute: a channel whose policy sets `require_confirmation_for_mutating` (the built-in `telegram` policy does) still gates **any** `mutating: true` tool via `PolicyGate::requiresConfirmation()`, regardless of the tool's own `requiresConfirmation` flag. On `cli`, `mcp`, and `web` (none of which set that policy by default) the bypass is total.

### Through the registry: request → resolve in two calls

[](#through-the-registry-request--resolve-in-two-calls)

Calling either tool via `$registry->call()` runs its handler directly — no generic confirm-token wrapper in between. A full request → resolve round trip is exactly two calls, one per tool:

```
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;

(new VerificationTool(new HumanVerifier()))->register($registry);

$request = $registry->call('request_verification', [
    'subject' => 'gate:report.publish',
], $ctx);
// -> ToolResult success, data: [
//      'subject' => 'gate:report.publish', 'policy' => 'single',
//      'request_id' => '06a1dda5-...',
//    ]
// handleRequest() ran on THIS call — HumanVerifier::verify() ran and dispatched
// `verification.requested`. No confirm_token anywhere: the registry gate never ran.

$registry->call('resolve_verification', [
    'request_id' => $request->data['request_id'],
    'decision' => 'grant',
    'principal' => 'agent:claude',
], $ctx);
// -> ToolResult success, data: [
//      'status' => 'passed', 'reason' => null, 'verifier' => 'human_verifier',
//      'principal' => 'agent:claude', 'missing' => [], 'metadata' => [],
//    ]
// HumanVerifier::grant() ran and dispatched `verification.granted`.
```

Echo the `request_id` from the first call's response back on `resolve_verification` — it is `HumanVerifier`'s own correlation id (#7), not the registry's `confirm_token`; neither tool mints or expects a `confirm_token`.

### The policy dividend: restrict `resolve_*` without touching `request_*`

[](#the-policy-dividend-restrict-resolve_-without-touching-request_)

Because the two phases are separate tools — not one tool with a conditional field — a host's policy can allow `request_verification` to any principal that can reach the registry while restricting `resolve_verification` to specific principals, using the same `scopes` mechanism every other tool in this package uses. `VerificationTool`'s constructor takes an optional `resolveScopes` list, applied only to `resolve_verification`'s `ToolOptions`:

```
use Milpa\ToolRuntime\Contracts\ToolContext;
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;

// request_verification stays open (empty scopes, the pre-split default); resolve_verification
// requires 'verification:resolve' — a scope only reviewer contexts carry.
(new VerificationTool(new HumanVerifier(), resolveScopes: ['verification:resolve']))
    ->register($registry);

$worker = new ToolContext(principal: 'agent:worker', channel: 'mcp', scopes: ['tasks:write']);
$reviewer = new ToolContext(
    principal: 'agent:reviewer',
    channel: 'mcp',
    scopes: ['tasks:write', 'verification:resolve'],
);

$request = $registry->call('request_verification', ['subject' => 'gate:report.publish'], $worker);
// -> success: $worker has no 'verification:resolve' scope, but request_verification never checks it.

$registry->call('resolve_verification', [
    'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:worker',
], $worker);
// -> FORBIDDEN: "Missing required scope for tool 'resolve_verification'. Need one of:
//    verification:resolve — context has: tasks:write." (see the pipeline's Authorize step
//    for the full FORBIDDEN-message-clarity contract)

$registry->call('resolve_verification', [
    'request_id' => $request->data['request_id'], 'decision' => 'grant', 'principal' => 'agent:reviewer',
], $reviewer);
// -> success: $reviewer carries the required scope.
```

`tests/Verification/VerificationToolPolicyDividendTest.php` pins exactly this scenario.

### Direct usage: calling the handlers without a registry

[](#direct-usage-calling-the-handlers-without-a-registry)

The same request → resolve round trip is also reachable by calling `handleRequest()` / `handleResolve()` directly, independent of any `ToolRegistry` — useful when you don't have a registry at hand (e.g. a unit test):

```
use Milpa\ToolRuntime\Verification\HumanVerifier;
use Milpa\ToolRuntime\Verification\VerificationTool;

$tool = new VerificationTool(new HumanVerifier($eventDispatcher));

$request = $tool->handleRequest(['subject' => 'gate:report.publish']);
// -> ToolResult::confirmation(), $request->data['request_id'] === '06a1dda5-...'
// HumanVerifier::verify() ran and dispatched `verification.requested`.

$tool->handleResolve([
    'request_id' => $request->data['request_id'],
    'decision' => 'grant',
    'principal' => 'agent:claude',
]);
// -> 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.

### Changed in 0.2: the double-gate bypass

[](#changed-in-02-the-double-gate-bypass)

Before 0.2, the combined verification tool used `requiresConfirmation: true`, so **any** call through `ToolRegistry::call()` — request or resolve alike — hit the registry's own step-4 confirmation gate *before* the tool's own handler ever ran. Opening a request took **two**registry calls just to reach the request phase (which itself returned a confirmation, this one carrying `request_id`) — and resolving it needed a **third**, itself gated the same way. The registry's generic wrapper carries no `request_id` (it knows nothing about `HumanVerifier`), so a caller only ever saw the correlation id after redeeming a token they didn't know they'd need. 0.2 set `requiresConfirmation: false` instead, since the handler's own `request_id` round trip already *is* the confirmation protocol — the registry's generic one was pure overhead for this tool specifically. 0.3's split preserves that same `requiresConfirmation: false` decision on both `request_verification` and `resolve_verification` — see [Verification](#verification-request_verification--resolve_verification)above.

### Events: what the payload actually carries

[](#events-what-the-payload-actually-carries)

`HumanVerifier` dispatches three events — `verification.requested` (from `verify()`), `verification.granted` and `verification.rejected` (from `grant()` / `reject()`) — through the optional `MilpaEventDispatcherInterface` passed to its constructor. Every dispatch uses the **same payload shape**: a single key, `'event'`, holding the event **object**, not a flattened array of the request's fields:

```
$dispatcher->dispatch('verification.requested', ['event' => $requestedEvent]);
// $requestedEvent instanceof Milpa\Events\VerificationRequestedEvent

$dispatcher->dispatch('verification.granted', ['event' => $grantedEvent]);
// $grantedEvent instanceof Milpa\Events\VerificationGrantedEvent

$dispatcher->dispatch('verification.rejected', ['event' => $rejectedEvent]);
// $rejectedEvent instanceof Milpa\Events\VerificationRejectedEvent
```

A listener reaches the data through the event object's accessors, **not** array keys — `$payload['subject']` is always `null`/undefined; the subject lives at `$payload['event']->getRequest()->subject`:

EventAccessors`VerificationRequestedEvent``getRequest(): VerificationRequest`, `getRequestId(): ?string``VerificationGrantedEvent``getRequest(): VerificationRequest`, `getResult(): VerificationResult`, `getRequestId(): ?string``VerificationRejectedEvent``getRequest(): VerificationRequest`, `getResult(): VerificationResult`, `getRequestId(): ?string`A listener that wants to work with any of the three (or with a future verifier's events) should branch on the event's class, or narrow via `getRequest()`/`getResult()`, rather than assume a flat array — this is defined and enforced by the event classes' own docblocks in `milpa/core` (`Milpa\Events\Verification{Requested,Granted,Rejected}Event`).

What lives where
----------------

[](#what-lives-where)

LayerPackageOwnsContracts`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 `HumanVerifier` reference verifier (`request_verification` / `resolve_verification`).Your appyour host / pluginsConcrete `PolicyRuleProviderInterface` (e.g. Doctrine-backed rules), `LoggerInterface`, channel renderers, and where policy decisions and audit logs are actually persisted.API de facto
------------

[](#api-de-facto)

The types you construct and pass around day to day:

TypeWhat it is`Contracts\ToolContext`Who/where/what-scopes for one call — `principal`, `channel`, `scopes`, `mode`. Named constructors per channel: `cli()`, `mcp()`, `stdio()` (trusted local stdio MCP server), `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, `getToolSummaries()` (plain-array LLM/MCP wire shape) / `getToolDefinitions()` (typed `list`) / `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.`Events\ToolExecutingEvent` / `Events\ToolExecutedEvent` / `Events\ToolFailedEvent`The three `tool.*` event VOs (0.5) dispatched around `ToolRegistry::call()` — see [Events](#events-toolexecuting--toolexecuted--toolfailed).Requirements
------------

[](#requirements)

- PHP **≥ 8.3**
- [`milpa/core`](https://packagist.org/packages/milpa/core) **^0.5** (the `InterceptionSlot` / `MilpaEventDispatcherInterface` keystone the [Events](#events-toolexecuting--toolexecuted--toolfailed) seam is built on)
- [`psr/log`](https://packagist.org/packages/psr/log) **^3**
- [`milpa/events`](https://packagist.org/packages/milpa/events) *(optional, dev-only)* — the reference `MilpaEventDispatcherInterface` implementation; any conformant implementation works, this package has no hard dependency on it

Documentation
-------------

[](#documentation)

**Full API reference: [getmilpa.github.io/tool-runtime](https://getmilpa.github.io/tool-runtime/)** — generated straight from the source DocBlocks and dressed with the Milpa design system.

Contributing
------------

[](#contributing)

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues via [SECURITY.md](SECURITY.md), and note that this project follows a [Code of Conduct](CODE_OF_CONDUCT.md).

License
-------

[](#license)

[Apache-2.0](LICENSE) © TeamX Agency.

---

Milpa is designed, built, and maintained by **[TeamX Agency](https://teamx.agency/?utm_source=github&utm_medium=readme&utm_campaign=milpa&utm_content=tool-runtime)**.

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance99

Actively maintained with recent releases

Popularity16

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity43

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

7

Last Release

2d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1993784?v=4)[rodrigomx](/maintainers/rodrigomx)[@rodrigomx](https://github.com/rodrigomx)

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/milpa-tool-runtime/health.svg)

```
[![Health](https://phpackages.com/badges/milpa-tool-runtime/health.svg)](https://phpackages.com/packages/milpa-tool-runtime)
```

###  Alternatives

[laravel/framework

The Laravel Framework.

34.8k543.8M20.3k](/packages/laravel-framework)[symfony/symfony

The Symfony PHP framework

31.4k87.2M2.2k](/packages/symfony-symfony)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k38.9k](/packages/matomo-matomo)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k15](/packages/tempest-framework)[drupal/core

Drupal is an open source content management platform powering millions of websites and applications.

21866.0M1.8k](/packages/drupal-core)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6942.5M423](/packages/drupal-core-recommended)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
