# wf_api Slice 4E: Capabilities Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Move workflow capability discovery, inspection, direct capability calls, wrapper capability projection, and capability-backed draft bootstrap out of `WorkflowSurfaceHandlers` into `wf_api.capabilities.WorkflowCapabilityApi`. **Architecture:** `WorkflowCapabilityApi` depends on `WorkflowOperationContext`, `WorkflowDraftApi`, and existing `wf_api` helper modules. It must not depend on `WfMcpService`, MCP events, MCP tools, or MCP request models. `WorkflowSurfaceHandlers` becomes a thin adapter/delegator for workflow operations. **Tech Stack:** Python 3.14+, `wf_api.operation_context`, `wf_api.drafts`, `wf_api.runs`, `wf_api.wrapper_hints`, `wf_api.refs`, `wf_artifacts`, `wf_authoring`, `wf_core.RuntimeContext`, pytest, ruff, basedpyright. --- ## Scope ### Move In This Slice Move these methods from `WorkflowSurfaceHandlers`: ```text list_capabilities inspect_capability call_capability create_draft_workspace_from_capability ``` Move these wrapper/capability private methods: ```text _wrapper_artifact_for_capability_name _wrapper_capability_summaries _wrapper_capability_detail _call_wrapper_artifact ``` Move or duplicate only the helpers needed by those methods: ```text _schema_field_names _source_id_for_capability _artifact_capability_id _raw_plan_from_artifact _required_capability_payloads _draft_name_from_capability ``` ### Do Not Move In This Slice Do not move MCP tool registration, MCP request/response Pydantic models, proxy/admin/broker runtime code, or CLI code. ### Invariants - No public payload changes. - No MCP tool schema changes. - `WorkflowSurfaceHandlers` public capability method signatures stay unchanged. - `wf_api` imports no `wf_mcp`. - Direct raw NodeSpec calls still use `build_async_registry`. - Direct wrapper calls still reject unsupported interrupting wrappers through `direct_wrapper_interrupt_diagnostic`. - Full saved workflows still run through deployments, not direct capability calls. - `create_draft_workspace_from_capability` keeps using inspect-capability wrapper hints. --- ## Design Notes Capability extraction is the final domain split because it spans several concepts: - live planner-visible source NodeSpecs - saved wrapper artifacts projected as workflow-facing capabilities - direct capability REPL calls - wrapper calls through workflow execution - wrapper-hint-driven draft bootstrap Keep this in one `WorkflowCapabilityApi` for now. Do not create five tiny services unless tests prove the file is too large after extraction. Temporary private helper duplication is allowed. A later cleanup can promote common helpers such as `artifact_capability_id`, `raw_plan_from_artifact`, and source snapshots into better shared modules. Do not widen this slice just to make helper names perfect. --- ## Task 1: Create `wf_api.capabilities` **Files:** - Create: `src/wf_api/capabilities.py` - Modify: `src/wf_api/__init__.py` - Test: `tests/wf_api/test_capability_api.py` - [ ] **Step 1: Create service skeleton** Create `src/wf_api/capabilities.py`: ```python from __future__ import annotations from collections.abc import Sequence from typing import Any from wf_artifacts import ( DependencyDiagnostic, DiagnosticSeverity, WorkflowArtifact, WorkflowCapabilityRef, ) from wf_authoring import build_async_registry from wf_core import RuntimeContext from wf_core.models.steps import InputBinding, OutputBinding from wf_core.paths import GraphSourcePath from wf_platform import CapabilitySource, page_items from .drafts import WorkflowDraftApi from .models import RawWorkflowPlan from .operation_context import WorkflowOperationContext from .refs import parse_workflow_surface_capability_id from .saved_subgraphs import direct_wrapper_interrupt_diagnostic from .wrapper_hints import ( workflow_output_schema_for_authoring, wrapper_hints_for_capability, ) class WorkflowCapabilityApi: """Workflow-facing capability discovery, inspection, and REPL calls. This service owns the source/wrapper projection, while adapter-specific MCP tool schemas stay outside wf_api. """ def __init__(self, context: WorkflowOperationContext) -> None: self.context = context self.drafts = WorkflowDraftApi(context) ``` - [ ] **Step 2: Add local list helpers** Add local helpers rather than importing `wf_mcp.shared`: ```python def _matches_query(*values: object, query: str | None) -> bool: if query is None: return True needle = query.strip().casefold() if not needle: return True return any(needle in str(value).casefold() for value in values if value is not None) def _paged_list_payload( key: str, items: Sequence[dict[str, Any]], *, cursor: str | None, limit: int, ) -> dict[str, Any]: page = page_items(items, cursor=cursor, limit=limit) return {key: list(page.items), "next_cursor": page.next_cursor, "total": page.total} ``` This duplicates current list behavior without importing MCP shared helpers into `wf_api`. - [ ] **Step 3: Export capability service** In `src/wf_api/__init__.py`: ```python from .capabilities import WorkflowCapabilityApi ``` Add `"WorkflowCapabilityApi"` to `__all__`. --- ## Task 2: Move Discovery And Inspection **Files:** - Modify: `src/wf_api/capabilities.py` - [ ] **Step 1: Move `list_capabilities`** Move the existing handler body into: ```python async def list_capabilities( self, *, query: str | None = None, source_id: str | None = None, cursor: str | None = None, limit: int = 50, ) -> dict[str, Any]: ... ``` Required replacements: ```python self.service.capability_sources -> self.context.capability_sources self._wrapper_capability_summaries(...) -> self._wrapper_capability_summaries(...) matches_query(...) -> _matches_query(...) paged_list_payload(...) -> _paged_list_payload(...) ``` Preserve sorting and response shape. - [ ] **Step 2: Move `inspect_capability`** Move the existing handler body into: ```python async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]: ... ``` Required replacements: ```python self.service.capability_sources -> self.context.capability_sources self._wrapper_capability_detail(...) -> self._wrapper_capability_detail(...) ``` Preserve: - enabled/planner visibility filtering - wrapper detail fallback - `KeyError(f"unknown workflow capability {qualified_name!r}")` - `wrapper_hints` payload - [ ] **Step 3: Add helper functions** Move or duplicate: ```text _schema_field_names _artifact_capability_id _required_capability_payloads ``` Do not import private helpers from `wf_api.artifacts` or `wf_api.runs` in this slice unless the import is already public. Private duplication is acceptable here. --- ## Task 3: Move Wrapper Capability Projection **Files:** - Modify: `src/wf_api/capabilities.py` - [ ] **Step 1: Add artifact store helper** Add: ```python def _artifact_store(self): return self.context.artifact_store ``` Do not raise from this helper. Existing wrapper projection returns no wrapper rows/details when artifact store is absent. - [ ] **Step 2: Move `_wrapper_artifact_for_capability_name`** Move the existing method, replacing: ```python self.service.artifact_store -> self.context.artifact_store ``` Preserve current behavior: - invalid capability ids return `None` - non-wrapper artifacts return `None` - missing artifact store returns `None` - missing artifact id/version returns `None` - [ ] **Step 3: Move `_wrapper_capability_summaries`** Move the method and replace: ```python matches_query(...) -> _matches_query(...) ``` Preserve `source_id not in {None, "workflow"}` filtering and the existing row shape. - [ ] **Step 4: Move `_wrapper_capability_detail`** Move the method unchanged except helper references now point to local functions. Preserve: - `kind == "wrapper_artifact"` - `required_capabilities` - `wrapper_hints` - output/input schema fields --- ## Task 4: Move Direct Capability Calls **Files:** - Modify: `src/wf_api/capabilities.py` - [ ] **Step 1: Move `call_capability`** Move the existing handler body into: ```python async def call_capability( self, *, qualified_name: str, payload: dict[str, Any], deployment_id: str | None = None, ) -> dict[str, Any]: ... ``` Required replacements: ```python self.service._get_qualified_spec(qualified_name) -> self.context.specs.get_qualified_spec(qualified_name) self.service.capability_sources -> self.context.capability_sources self._call_wrapper_artifact(...) -> self._call_wrapper_artifact(...) ``` Preserve direct NodeSpec call behavior: - build handler with `build_async_registry(spec)[spec.name]` - pass `RuntimeContext(current_node_id=spec.name)` - catch `Exception` and return `capability_call_failed` diagnostic payload - successful response returns `kind: "node_spec"` and empty diagnostics - [ ] **Step 2: Move `_call_wrapper_artifact`** Move the wrapper call method into `WorkflowCapabilityApi`. Required replacements: ```python self.service.artifact_store -> self.context.artifact_store self.service.run_workflow_from_plan(...) -> self.context.runtime.run_workflow_from_plan(...) ``` Call runtime with the same deployment/artifact arguments: ```python run = await self.context.runtime.run_workflow_from_plan( plan, payload, deployment=deployment, artifact=artifact, ) ``` Preserve: - `direct_wrapper_interrupt_diagnostic` rejection - deployment target validation - `kind: "wrapper_artifact"` - `outcome: run.status.value` - `output: run.output` - [ ] **Step 3: Move raw plan helper** Move or duplicate: ```text _raw_plan_from_artifact _plan_field ``` Do not import private `_raw_plan_from_artifact` from `wf_api.runs` unless you first make it public. Keeping a local copy is acceptable for this slice. --- ## Task 5: Move Capability-Backed Draft Bootstrap **Files:** - Modify: `src/wf_api/capabilities.py` - [ ] **Step 1: Move `create_draft_workspace_from_capability`** Move the existing handler body into `WorkflowCapabilityApi`. Required replacements: ```python capability = await self.inspect_capability(...) result = await self.drafts.create_minimal_draft_workspace(...) ``` Preserve: - wrapper-hint defaults - explicit `input` overrides `input_map` - explicit `output` overrides `output_map` - returned `wrapper_hints` - returned `next_actions` - [ ] **Step 2: Move `_draft_name_from_capability`** Move or duplicate: ```python def _draft_name_from_capability(capability_name: str) -> str: """Return a stable draft name when caller does not provide one.""" return capability_name.replace(".", "_").replace("-", "_") ``` --- ## Task 6: Wire `WorkflowSurfaceHandlers` **Files:** - Modify: `src/wf_mcp/workflow_surface/handlers.py` - [ ] **Step 1: Add import and instance** Add: ```python from wf_api.capabilities import WorkflowCapabilityApi ``` In `WorkflowSurfaceHandlers.__init__`, reuse the existing operation context: ```python context = context_from_service(service) self._capabilities = WorkflowCapabilityApi(context) self._drafts = WorkflowDraftApi(context) self._artifacts = WorkflowArtifactApi(context) self._deployments = WorkflowDeploymentApi(context) self._runs = WorkflowRunApi(context) ``` - [ ] **Step 2: Delegate moved methods** Replace method bodies for: ```text list_capabilities inspect_capability call_capability create_draft_workspace_from_capability ``` Example: ```python async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]: """Return one planner-visible workflow capability contract.""" return await self._capabilities.inspect_capability(qualified_name=qualified_name) ``` - [ ] **Step 3: Remove moved private methods** Remove these from `handlers.py` after delegation: ```text _wrapper_artifact_for_capability_name _wrapper_capability_summaries _wrapper_capability_detail _call_wrapper_artifact ``` Then run: ```powershell rg -n "_schema_field_names|_source_id_for_capability|_artifact_capability_id|_raw_plan_from_artifact|_plan_field|_draft_name_from_capability|_required_capability_payloads" src/wf_mcp/workflow_surface/handlers.py ``` Remove each helper only if it has no remaining handler caller. The target after 4E should be close to zero private workflow-domain helpers in `handlers.py`. - [ ] **Step 4: Prune imports** Use `ruff check` to remove unused imports. Likely candidates: ```text DependencyDiagnostic DiagnosticSeverity WorkflowArtifact WorkflowCapabilityRef CapabilitySource build_async_registry RuntimeContext direct_wrapper_interrupt_diagnostic workflow_output_schema_for_authoring wrapper_hints_for_capability parse_workflow_surface_capability_id matches_query paged_list_payload ``` Do not remove imports still needed by method signatures such as `InputBinding`, `OutputBinding`, `GraphSourcePath`, `TraceRange`, or `RawWorkflowPlan`. --- ## Task 7: Add Focused Capability API Tests **Files:** - Create: `tests/wf_api/test_capability_api.py` - [ ] **Step 1: Cover live source capability listing and inspection** Build a service with `echo_tool`, adapt with `context_from_service`, instantiate `WorkflowCapabilityApi`, and assert: ```python listed = asyncio.run(api.list_capabilities()) assert listed["total"] >= 1 assert any(item["name"] == "demo.personal.echo_tool" for item in listed["capabilities"]) detail = asyncio.run(api.inspect_capability(qualified_name="demo.personal.echo_tool")) assert detail["name"] == "demo.personal.echo_tool" assert "wrapper_hints" in detail ``` - [ ] **Step 2: Cover direct NodeSpec call** Call: ```python result = asyncio.run( api.call_capability( qualified_name="demo.personal.echo_tool", payload={"text": "hello"}, ) ) ``` Assert stable fields: ```python assert result["kind"] == "node_spec" assert result["outcome"] == "ok" assert result["diagnostics"] == [] ``` - [ ] **Step 3: Cover saved wrapper projection** Save a wrapper artifact and assert: - `list_capabilities(source_id="workflow")` includes `kind == "wrapper_artifact"` - `inspect_capability(qualified_name="workflow..v")` returns wrapper detail - `call_capability(...)` executes the wrapper through runtime and returns `kind == "wrapper_artifact"` Use existing artifact helpers where possible. Do not duplicate entire run tests. - [ ] **Step 4: Cover capability-backed draft bootstrap** Call `create_draft_workspace_from_capability(...)` and assert: ```python assert result["workspace_id"] == "echo_ws" assert result["revision"] == 1 assert "wrapper_hints" in result assert "next_actions" in result ``` Fetch the workspace through `WorkflowDraftApi` and assert the draft uses the expected capability name. - [ ] **Step 5: Cover handler delegation smoke** Compare stable fields from handler and direct API for one method: ```python handler_result = asyncio.run(handlers.inspect_capability(qualified_name=name)) api_result = asyncio.run(api.inspect_capability(qualified_name=name)) assert handler_result["name"] == api_result["name"] assert handler_result["kind"] == api_result["kind"] ``` Do not duplicate every capability behavior test in both layers. --- ## Task 8: Verification - [ ] **Step 1: Run focused capability tests** ```powershell uv run pytest tests/wf_api/test_capability_api.py tests/wf_mcp/workflow_surface/test_capabilities.py -q ``` If `tests/wf_mcp/workflow_surface/test_capabilities.py` does not exist, run the closest existing workflow-surface capability tests discovered by `rg -n "call_capability|inspect_capability|list_capabilities" tests/wf_mcp`. Expected: pass. - [ ] **Step 2: Run adjacent API tests** ```powershell uv run pytest tests/wf_api/test_drafts_service.py tests/wf_api/test_artifact_api.py tests/wf_api/test_deployment_api.py tests/wf_api/test_run_api.py -q ``` Expected: pass. - [ ] **Step 3: Run import-direction test** ```powershell uv run pytest tests/wf_api/test_import_direction.py -q ``` Expected: pass; `wf_api` has no `wf_mcp` imports. - [ ] **Step 4: Run ruff on touched files** ```powershell uv run ruff check src/wf_api/capabilities.py src/wf_api/__init__.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_api.py ``` Expected: all checks pass. - [ ] **Step 5: Run basedpyright on touched files** ```powershell uv run basedpyright --level error src/wf_api/capabilities.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_api.py ``` Expected: `0 errors`. - [ ] **Step 6: Optional full suite** ```powershell uv run pytest -q ``` Expected: full suite passes with the project’s existing skipped/xfailed counts. --- ## Self-Review Checklist - `wf_api.capabilities` imports no `wf_mcp`. - `WorkflowSurfaceHandlers` public capability signatures are unchanged. - `create_draft_workspace_from_capability` moved with capability inspection. - Direct NodeSpec calls still work. - Saved wrapper discovery and direct wrapper calls still work. - Full saved workflows still require deployments. - No public payload shape changed. - No MCP schema changed. - Handler is now mostly a thin compatibility adapter over `wf_api` domain services. ## Follow-Up Cleanup After 4E After this slice lands, consider a cleanup plan to promote shared helpers: ```text wf_api.runs._raw_plan_from_artifact -> wf_api.artifact_plans.raw_plan_from_artifact wf_api.capabilities._artifact_capability_id -> wf_api.artifact_refs.artifact_capability_id wf_api.deployments._available_sources -> wf_api.source_snapshots.available_sources_from_capability_sources ``` Do not do that cleanup inside 4E unless it is required to remove circular imports or duplicate behavior bugs.