P4.7 Phase 1 — read-class tool dispatch to Hermes (direction A, stdio MCP) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (
- [ ]) syntax. This plan touches BOTH services (agent Python + api NestJS) and changes the/chatwire contract — the wire-contract task is a paired agent PR (seeagent/CLAUDE.md).
Goal: Make a Specialist's configured business tools actually reachable by the Hermes runtime during a /chat draft turn — for read-class tools only (Phase 1). Fix the dead -t wiring, expose business tools to Hermes via a thin stdio MCP server that forwards to the existing tool-gateway, and pass the active-skills.tools ∩ tool_bindings.toolSlug intersection as the selected toolset. Write-class tools are deliberately NOT exposed this phase — their Expert-approval carrier (ExpertQueueItem) has an unresolved messageId gap, deferred to Phase 2.
Architecture (direction A, validated by the 2026-07-09 spike docs/superpowers/specs/2026-07-09-p4.7-hermes-runtime-spike-results.md): The unified tool layer is the ALREADY-LIVE control-plane gateway (POST /v1/runtime/tool-gateway/execute). A thin stdio MCP server (new, in agent/) exposes the business tools to Hermes; each handler forwards to the gateway via the ALREADY-EXISTING agent/tool_gateway_client.py. Credentials/business logic/policy stay in the control plane; Hermes only knows the MCP server name (-t humanwork) and inherits the runner token. Read-class calls execute straight through the gateway (no approval).
Tech stack: agent = FastAPI + Hermes v0.17.0 binary (same container, /opt/hermes/.venv/bin/hermes); MCP server = Python stdio (greenfield, no repo precedent); api = NestJS 11; wire = AgentClient ↔ agent /chat.
Context the implementer needs (verified @ feat/specialist-value-output-p2plus, Explore report 2026-07-09)
Spike-validated facts: -t '<json>' → "Unknown toolsets" (bug live at agent/main.py:750-755 + hermes_client.py:353-354); an mcp_servers KEY is a -t-selectable toolset (cli.py:3639 admits t in mcp_names); business tool names (order_lookup, …) are NOT built-in toolsets (validate_toolset(...)=False), so they MUST come via MCP. Approval cannot ride Hermes — but Phase 1 has no approval (read-only), so that's Phase 2's concern.
Existing assets to REUSE (do not rebuild):
agent/tool_gateway_client.py::execute_tool_via_gateway(:29-210) — alreadyPOSTs{PLATFORM_API_URL}/v1/runtime/tool-gateway/executewithAuthorization: Bearer <runner token>, payload matchingExecuteToolDto(orgId, runId, toolName, action, params, correlationId+ optionalriskLevel/toolVersion/providerKind/integrationType), handles 403→blocked / 500→failed / 404→raise / one timeout retry. The MCP handler is a thin wrapper over THIS.agent/platform_auth.py::platform_api_token()(:8-20) — runner token (HUMANWORK_RUNNER_TOKEN→ … →PLATFORM_API_TOKEN), theaud:'runner'tokenRunnerTokenGuardverifies.- Gateway:
tool-execution.controller.ts:66-73(POST v1/runtime/tool-gateway/execute,@Public()+RunnerTokenGuard),ExecuteToolDto(:18-60). Read-class →ActionPolicyService.decidereturnsallow(action-policy.service.ts:117) →RuntimeToolExecutor.execute(runtime-tool-executor.service.ts:29,AGENT_TOOL_REGISTRY[toolName]dispatch, credential by(orgId, integrationType):75-87). Read tools need NO gateway change — they already execute. ConfigAssetsService.getPublishedSkills({orgId, specialistId})(config-assets.service.ts:554) →PublishedSkill[]with fullcontent.tools[](slug array,skill.schema.ts:47, regex/^[a-z0-9_]{1,64}$/).SkillTriggerService.evaluateDROPStools[](skill-trigger.service.ts:34-42) — Phase 1 must re-fetchgetPublishedSkillsfortools[], mirroring how P3.5 re-fetched redLines.tool-binding.schema.ts:toolSlug(:14, same regex → intersect by string),accessClass: read|write(:16),kind: bespoke|nango(:15—mcpdeliberately absent, it's this task's addition).AGENT_TOOL_REGISTRY(agent-api/tool-registry.ts:18, 7 tools,kind: AgentToolKind,handler,integrationTypes,actions[]) is the executor's dispatch source.TOOL_CATALOG(tools/tool-catalog.ts:24, 3 tools, Claude-format) feedsbuildToolsManifest. Neither hasaccessClasstoday.
Wire contract facts: /chat currently carries NO tool info — AgentChatRequest/buildV1ChatPayload (agent.client.ts) have no toolsets/tools_manifest; AGENT_API_SPEC.md doesn't list it. agent main.py:308 CAN receive tools_manifest (AliasChoices) but NestJS never sends it. So adding a toolset field to /chat is a paired agent+api PR (agent/CLAUDE.md: "Don't add new request fields to /chat without a paired NestJS PR").
Deploy facts (stdio): hermes binary in the agent container (agent/Dockerfile:17); Hermes home / cli-config at /opt/data/config.yaml (HERMES_HOME, :89), volume-shadowed so config is re-asserted at boot (:94 || true pattern). No mcp_servers written today (greenfield). hermes_client._runner_subprocess_env (:250-329) builds the hermes subprocess env with a secret fence (:259) but ALREADY allowlists HUMANWORK_RUNNER_TOKEN/HUMANWORK_MODEL_GATEWAY_TOKEN past it (:324-327); the stdio MCP server inherits this env. Gap: PLATFORM_API_URL has no HUMANWORK_/HERMES_ prefix so it's NOT passed through the :274 prefix filter — must be added explicitly so the MCP handler's tool_gateway_client has a base URL.
DECISION POINTS (reversible defaults — user may override)
- DP-1 — Phase split: read-class only this phase (USER-DECIDED). write-class tools are filtered OUT of the intersection before
-t, so Hermes is never even offered a write tool. Avoids the ExpertQueueItemmessageIdgap entirely. Phase 2 does write + approval. - DP-2 — MCP server exposes tools from
AGENT_TOOL_REGISTRY(the executor's real dispatch source, 7 tools, has handler/integrationType), NOTTOOL_CATALOG(3-tool Claude-format manifest feed). The-tname is the single MCP serverhumanwork; individual tools are MCP tools under it. - DP-3 — Intersection computed NestJS-side, passed as a toolset name over
/chat. Alternative (agent computes intersection) would need agent to read config-assets — a cross-service coupling the platform avoids. NestJS already owns skills+bindings reads. So/chatcarries the resolved toolset name(s); agent just forwards to-t. - DP-4 — Phase 1 exposes the single
humanworkMCP toolset when the intersection is non-empty; empty → no-t(tool-free, today's behaviour). Per-tool-tgranularity isn't needed — the MCP server advertises exactly the intersected read tools; Hermes selects the server, the server lists its tools.
File structure
agent (Python):
- Create
agent/humanwork_mcp_server.py— stdio MCP server; handlers wraptool_gateway_client.execute_tool_via_gateway. - Modify
agent/main.py:750-755— stopjson.dumps-ing the manifest into-t; pass the toolset name from the new wire field. - Modify
agent/hermes_client.py—_runner_subprocess_env: addPLATFORM_API_URLpassthrough. - Modify
agent/Dockerfile+ boot — writemcp_servers: { humanwork: {command…} }into/opt/data/config.yaml, re-assert at boot. - Modify
agent/AGENT_API_SPEC.md+agent/CLAUDE.md— document the new/chattoolset field + the MCP server.
api (NestJS):
- Create
api/src/config-assets/tool-binding.reader.ts(or a method on ConfigAssetsService) —getPublishedToolBindings({orgId, specialistId})(greenfield runtime reader; P2.5 only stored). - Create the intersection util —
resolveSpecialistToolNames({orgId, specialistId}):getPublishedSkills.content.tools[]∩ publishedtool_binding.toolSlug, filtered toaccessClass === 'read'(Phase 1). - Modify
api/src/common/agent.client.ts—AgentChatRequest+buildV1ChatPayload: addtoolsets?: string[](orreadToolsetName?). - Modify
api/src/conversations/conversations.service.ts— at the two.chat()dispatch sites, resolve the intersection and pass it (flag-gated). - Modify
api/src/config-assets/tool-binding.schema.ts— addmcptokindenum (P4.6 absorbed). - Add feature flag
tool_dispatch_enabled(default OFF) — Phase 1 ships dark; flip per-org after the live Hermes integration check.
Task 1: getPublishedToolBindings runtime reader (NestJS)
Files: Modify api/src/config-assets/config-assets.service.ts; Test api/test/config-assets-tool-bindings.spec.ts
- Step 1: Failing test — mirror
getPublishedSkillstests: seed a publishedtool_bindingorg_instance asset, assertgetPublishedToolBindings({orgId, specialistId})returns[{assetId, versionId, slug, content: ToolBindingContentT}]with dual-scope filter (org AND specialist),archivedAt: IsNull(), half-valid content skipped, and fail-open to[]on any error (it will sit on the /chat path). Include a case proving ascope:"catalog"binding is NOT returned and a wrong-specialist binding is NOT returned (ADR-020).
// api/test/config-assets-tool-bindings.spec.ts (shape — align to getPublishedSkills spec's datasource helper)
it("returns published tool_binding content dual-scoped, fail-open to []", async () => {
// seed published tool_binding for (ORG, SPEC); assert reader returns it with content.toolSlug + accessClass
// seed one for a different specialist; assert excluded
// corrupt one version's content; assert skipped, others returned
});
-
Step 2: Run → fail.
cd api && npx jest test/config-assets-tool-bindings.spec.ts -
Step 3: Implement
getPublishedToolBindingsonConfigAssetsService, copyinggetPublishedSkills(config-assets.service.ts:554) verbatim in structure: same dual-scopewhere(assetType:"tool_binding",scope:"org_instance",orgId,specialistId,archivedAt: IsNull()), same published-version resolution, sameToolBindingContent.safeParseskip-half-valid, same exception-level fail-open →[]. ReturnPublishedToolBinding[]({assetId, versionId, versionNo, slug, updatedAt, content: ToolBindingContentT}). Read the realgetPublishedSkillsfirst and mirror it exactly — do not invent a different shape. -
Step 4: Run → pass. Same command.
-
Step 5: Commit —
feat(config-assets): getPublishedToolBindings runtime reader (P4.7 P1)
Task 2: Tool-name intersection util (NestJS)
Files: Create api/src/conversations/tool-resolution.util.ts; Test api/test/tool-resolution.spec.ts
- Step 1: Failing test — pure function
intersectReadTools(skills: PublishedSkill[], bindings: PublishedToolBinding[]): string[]:- returns slugs present in BOTH
skill.content.tools[](union across matched skills) AND abinding.content.toolSlug - filters OUT bindings whose
content.accessClass !== "read"(Phase 1 — write tools never surfaced) - dedups, stable-sorts (deterministic order for a stable
-t) - empty inputs / no overlap →
[] - never throws
- returns slugs present in BOTH
it("intersects skill tools with read-class bindings only", () => {
const skills = [{content:{tools:["order_lookup","refund_issue"]}}] as any;
const bindings = [
{content:{toolSlug:"order_lookup", accessClass:"read"}},
{content:{toolSlug:"refund_issue", accessClass:"write"}}, // dropped (write)
{content:{toolSlug:"unused", accessClass:"read"}}, // not in any skill
] as any;
expect(intersectReadTools(skills, bindings)).toEqual(["order_lookup"]);
});
- Step 2: Run → fail.
- Step 3: Implement the pure function (no DB). Union skill tools → Set; filter bindings to
accessClass==="read"; keep binding slugs in the skill set; dedup + sort. Never throws. - Step 4: Run → pass.
- Step 5: Commit —
feat(conversations): read-tool intersection util (P4.7 P1)
Task 3: mcp kind + accessClass plumbing prep (NestJS, no behaviour change)
Files: Modify api/src/config-assets/schemas/tool-binding.schema.ts; Modify api/test/*tool-binding.schema*.spec.ts
- Step 1: Failing test — assert
ToolBindingContentnow acceptskind: "mcp"(P4.6 absorbed) alongsidebespoke|nango, and still rejects unknown kinds. Keep all existing schema assertions green. - Step 2: Run → fail (mcp currently rejected).
- Step 3: Add
"mcp"to thekindenum intool-binding.schema.ts:15. Update the comment that said "nomcp(P4.6 addition absent from code)" to note P4.7 P1 added it. Do NOT changeaccessClass(alreadyread|write). - Step 4: Run → pass (schema + any registry/golden assertion referencing kinds).
- Step 5: Commit —
feat(config-assets): add mcp to tool_binding kind (P4.6 absorbed into P4.7 P1)
Task 4: stdio Humanwork MCP server (agent, greenfield)
Files: Create agent/humanwork_mcp_server.py; Test agent/tests/test_humanwork_mcp_server.py
-
Step 1: Read the reuse targets + confirm the MCP SDK.
agent/tool_gateway_client.py::execute_tool_via_gatewaysignature + return shape;agent/platform_auth.py::platform_api_token. VERIFIED MCP-SDK situation (coordinator, 2026-07-09): the agent requirements do NOT currently include an MCP SDK, and Hermes itself is the MCP client (~/.hermes/hermes-agent/tools/mcp_tool.py:119uses "the MCP SDK'sstdio_client"; its example servers at:17,:27arecommand: "npx"= Node servers). So our Python stdio server needs themcpPyPI package's SERVER side (mcp.server/ theFastMCPhelper), which is not yet a dep. Task 4 therefore ADDSmcptoagent/requirements*.txt(pin a version compatible with Hermes's client — checkmcp_tool.pyimports / hermes's own pin for the compatible major). Read the installedmcppackage's server API before coding the handler (the server-side decorator/registration API differs acrossmcpversions). Do NOT hand-roll JSON-RPC framing — use the SDK. If pinning a compatiblemcpversion proves impossible (Hermes client incompatibility), STOP and report — the fallback is an HTTP-transport MCP server (Hermes supportsurl:transport permcp_tool.py:5), which changes deploy shape (Phase-1 decision would return to the user). -
Step 2: Failing test — the handler logic is unit-testable WITHOUT Hermes: given a tool name + action + params, it calls
execute_tool_via_gateway(mocked) with the rightExecuteToolDtofields and returns the gateway's result as the MCP tool result. Test: (a) a read tool forwardsorgId/runId/toolName/action/params/correlationIdcorrectly, (b) gateway 403→blocked is surfaced as a tool error not a crash, (c) the server advertises exactly the tools it's configured with.
# agent/tests/test_humanwork_mcp_server.py
def test_handler_forwards_to_gateway(monkeypatch):
calls = {}
async def fake_exec(**kw): calls.update(kw); return {"status":"success","result":{"ok":True}}
monkeypatch.setattr("humanwork_mcp_server.execute_tool_via_gateway", fake_exec)
# invoke the handler for tool "order_lookup" action "get" params {...}
# assert calls has orgId/runId/toolName="order_lookup"/action="get"/params/correlationId
# assert result surfaces {"ok": True}
-
Step 3: Implement
agent/humanwork_mcp_server.py— a stdio MCP server that:- reads its tool set +
orgId/runIdcontext from env (injected by Hermes subprocess env:HUMANWORK_ORG_ID,HUMANWORK_RUN_ID,PLATFORM_API_URL, runner token viaplatform_api_token()), - advertises one MCP tool per configured business tool (from a passed-in list — Phase 1 read tools),
- each handler calls
execute_tool_via_gateway(orgId=…, runId=…, toolName=…, action=…, params=…, correlationId= …)and returns itsresult(or surfaces a structured error on blocked/failed), - runs over stdio (the transport Hermes spawns). Keep it THIN — no business logic, no credential handling (all in the gateway). Match the confirmed MCP transport from Step 1.
- reads its tool set +
-
Step 4: Run → pass.
cd agent && python -m pytest tests/test_humanwork_mcp_server.py -v(use the agent venv). Alsopython -c "import humanwork_mcp_server"to prove it imports clean. -
Step 5: Commit —
feat(agent): thin stdio Humanwork MCP server forwarding to tool-gateway (P4.7 P1)
Task 5: Fix the -t wiring + PLATFORM_API_URL passthrough (agent)
Files: Modify agent/main.py:750-755; Modify agent/hermes_client.py (_runner_subprocess_env); Test agent/tests/ (existing hermes_client / main tests)
-
Step 1: Read
agent/main.py:745-780(the toolsets_json → HermesClient → chat block) andagent/hermes_client.py:250-360(_runner_subprocess_env+ the-targ build at:353). -
Step 2: Failing test — assert that when the new wire field (Task 6) carries a toolset name (e.g.
["humanwork"]),HermesClientis constructed withtoolsets="humanwork"(comma-joined names) and the-targ is["-t", "humanwork"]— NOT ajson.dumpsof a manifest. And that_runner_subprocess_envincludesPLATFORM_API_URLwhen set in the parent env. -
Step 3: Implement:
main.py:752— replacetoolsets_json = json.dumps(req.tools_manifest) …with: derive the comma-separated toolset name(s) from the new wire field (Task 6'sreq.toolsets: list[str]), e.g.toolsets_arg = ",".join(req.toolsets) if req.toolsets else "". Drop thejson.dumps(manifest)path entirely.hermes_client.py_runner_subprocess_env— addPLATFORM_API_URLto the explicit passthrough (alongside the existingHUMANWORK_*allowlist at:324-327) so the spawned MCP server'stool_gateway_clienthas a base URL. Also passHUMANWORK_ORG_ID/HUMANWORK_RUN_IDif the MCP server reads context from env (align with Task 4's env contract).
-
Step 4: Run → pass.
cd agent && python -m pytest tests/ -k "hermes_client or toolset or chat" -v. Re-run the echo-guard / assembly regression the program relies on:python -m pytest tests/ -k "echo or assembly or chat_helpers" -v(P3.1 executor branch must not regress). -
Step 5: Commit —
fix(agent): pass toolset NAME to -t (not json manifest) + PLATFORM_API_URL passthrough (P4.7 P1)
Task 6: /chat wire field (PAIRED agent + api PR)
Files: Modify api/src/common/agent.client.ts (AgentChatRequest + buildV1ChatPayload); Modify agent/main.py (ChatRequest model, :308 area); Modify agent/AGENT_API_SPEC.md; Test both sides.
-
Step 1: agent side — add the field. In agent's request model (
main.pyaround thetools_manifestField:308), addtoolsets: list[str] = Field(default_factory=list, validation_alias=AliasChoices("toolsets","tool_names")). This is what Task 5 reads. Keeptools_manifestfor backwards-compat but it's no longer the-tsource. -
Step 2: api side — send it. In
agent.client.ts: addtoolsets?: string[]toAgentChatRequest; inbuildV1ChatPayloadincludetoolsets: req.toolsets ?? []in the payload. Add a test asserting the payload carriestoolsets. -
Step 3: Doc the contract. Update
agent/AGENT_API_SPEC.md(the/chatrequest schema) +agent/CLAUDE.md(note the new field + that it's the-tsource; MCP server is the carrier) +api/src/conversations/CLAUDE.mdif it documents the wire. -
Step 4: Verify both sides. api:
cd api && npx jest agent.client. agent:cd agent && python -m pytest tests/ -k "chat_request or payload or toolset". Both green; wire field round-trips. -
Step 5: Commit —
feat(chat): add toolsets field to /chat wire (paired agent+api, P4.7 P1)
Task 7: Wire the intersection into /chat dispatch (NestJS, flag-gated)
Files: Modify api/src/conversations/conversations.service.ts (two .chat() sites); Add flag tool_dispatch_enabled; Test api/test/ conversations dispatch.
-
Step 1: Add flag
tool_dispatch_enabled(default OFF) tofeature-flags.constants.ts(FLAG_KEYS + DEFAULT_FLAG_VALUES) + the golden assertion infeature-flag.service.spec.ts. Default OFF = Phase 1 ships dark (byte-identical /chat when off). -
Step 2: Failing test — at the main dispatch site, when
tool_dispatch_enabledis ON for the org,AgentClient.chatis called withtoolsets: ["humanwork"]IFFresolveSpecialistToolNamesreturns a non-empty read-tool intersection; when the intersection is empty OR the flag is OFF,toolsetsis[]/absent (tool-free, byte-identical to today). MockgetPublishedSkills+getPublishedToolBindings. -
Step 3: Implement — a private
resolveSpecialistToolNames(orgId, specialistId)on ConversationsService (or a small injected resolver) that: flag-check → re-fetchgetPublishedSkills+getPublishedToolBindings→intersectReadTools(...)(Task 2) → returnintersection.length ? ["humanwork"] : []. Call it at BOTH.chat()dispatch sites (main + regenerate) and passtoolsetsintoAgentChatRequest. Strictly flag-gated: OFF → don't call the resolver,toolsetsabsent (no behaviour change, no extra DB reads). Fail-open: resolver error →[](tool-free), never break the turn. -
Step 4: Run → pass + regression:
cd api && npx jest conversations config-assets feature-flag(no regression; flag-OFF path byte-identical). -
Step 5: Commit —
feat(conversations): resolve read-tool intersection into /chat toolsets, flag-gated (P4.7 P1)
Task 8: cli-config mcp_servers injection + boot re-assert (agent deploy)
Files: Modify agent/Dockerfile; Modify agent boot/entrypoint (the CMD that re-asserts config, Dockerfile:94 area).
-
Step 1: Read
agent/Dockerfile:85-95(theHERMES_HOME=/opt/data/config.yaml+hermes plugins enable … || trueboot re-assert pattern) to mirror it. -
Step 2: Inject
mcp_servers. Ensure/opt/data/config.yamlgets anmcp_servers: { humanwork: { command: "<python>", args: ["<path>/humanwork_mcp_server.py"], env: {...} } }entry. Because the volume shadows build-time config, do it in the boot CMD (re-assert every boot, idempotent,|| truelike the langfuse/plugins pattern) — either via ahermes mcp add-style CLI if one exists (checkhermes --help), or by merging a YAML fragment into the existing config at boot with a tiny idempotent script. Do NOT clobber other config sections (github/notion/slack may be operator-added in some envs — merge, don't overwrite). -
Step 3: Verify the injection is idempotent + non-destructive: boot twice, assert
humanworkpresent once and any pre-existingmcp_serverskeys survive. (A shell/python assertion in the build or a note for the deploy check — this is infra, hard to unit-test; document the manual verification.) -
Step 4: Commit —
feat(agent): register humanwork stdio MCP server in Hermes cli-config at boot (P4.7 P1)
Self-review checklist
- Read-only this phase: no write-class tool ever reaches
-t(Task 2 filtersaccessClass!=="read"); no ExpertQueueItem/approval code touched (that's Phase 2). Grep the diff forExpertQueueItem/ApprovalRequest→ should be absent. - Flag-OFF byte-identical:
tool_dispatch_enabledOFF → resolver not called,toolsetsabsent,/chatpayload unchanged,-tunset (tool-free = today). Verify in Task 7 test. - Reuse not rebuild: MCP handler wraps
tool_gateway_client(no new HTTP); reader mirrorsgetPublishedSkills; gateway read path untouched. - Paired PR integrity: Task 6 changes both sides +
AGENT_API_SPEC.md+ CLAUDE.md together. - ADR-020:
getPublishedToolBindingsdual-scoped (org AND specialist), fail-open[]. - Credentials never in agent/MCP: the MCP server only forwards toolName/action/params + runner token; the gateway resolves credentials server-side (unchanged).
Verification (verify skill, after all tasks)
- api:
cd api && npx tsc --noEmitclean;npx jest config-assets conversations feature-flag tool-resolutiongreen. - agent:
cd agent && python -m pytest tests/ -k "mcp or hermes_client or toolset or chat or echo or assembly"green (incl. echo-guard regression). - Live integration check (the real payoff — env permitting): with
tool_dispatch_enabledON for a test org that has a published read-classtool_bindingwhosetoolSlugis in a matched skill'stools[], drive one/chatturn and confirm Hermes selects-t humanwork, calls the MCP tool, the MCP handler forwards to the gateway, and the gateway executes the read tool. Fallback if the full local stack is blocked (memoryverification-env-gotchasschema-drift): the unit tests already prove each seam (reader, intersection, MCP handler→gateway,-tname); a narrower check is to runhumanwork_mcp_server.pystandalone over stdio with a mocked gateway and confirm Hermes (hermes chat -Q -t humanwork) lists + calls its tool. Document which level of verification was achieved. git grep -n "accessClass" api/src— confirm the write-class filter is the only accessClass gate this phase (no premature approval logic).
What Phase 2 will add (explicitly NOT this plan)
Write-class exposure + Expert approval: accessClass=write into the intersection, ActionPolicyService.decide gains an accessClass branch, the gateway approval branch moves from ApprovalRequest to ExpertQueueItem — which needs the messageId gap resolved (AgentRun has only conversationId; ExpertQueueItem requires messageId). That gap's resolution is a Phase 2 design decision (deferred per the user's phase-split call).