Skip to main content

P2.5 tool_binding Asset Type (Storage Half) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax.

Goal: Ship the tool_binding config-asset type's storage half — a Zod content schema, CONTENT_SCHEMAS registration, and an Ops editor — so operators can author/version/publish tool bindings through the existing config-asset base. NOT the execution half (chat-path tool exposure, slug-exists validation, credential resolution, write→Expert approval) — all of that is P4.7.

Architecture: tool_binding is already in the ConfigAssetType enum, the migration CHECK, and the DTOs. So this is a pure additive change: one schema file + one registry line + one frontend editor + one page branch. The existing publish state machine, CRUD controller, importer framework, and RLS all apply automatically. No migration, no controller change, no DTO change.

Tech Stack: Zod v4 (config-asset schemas), NestJS config-assets module, Next.js Ops editor (React 19).

Design source: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §3 (ToolBindingContent) — with 3 verified corrections (the design's placeholders diverge from real code, verified @ 490517fb):

  1. sourceKindkind — real field on AgentToolDescriptor (api/src/agent-api/tool-registry.ts:1) is kind.
  2. enum drops mcp — real AgentToolKind = 'bespoke' | 'nango' (no mcp; mcp is a P4.6 addition that does not exist in code yet — pre-seeding it would break the P4.7 service-layer registry cross-check).
  3. credentialRef scope — references an integration_credentials row at (org_id, integration_type) granularity (@Unique(["orgId","integrationType"]), entities.ts:1156), NOT the ADR-030 OSA/Channel dimension the design comment claims.

Scope note: Long-lived branch feat/specialist-value-output-p2plus. Accumulates with P2.4 and later phases into one PR; NOT merged per-phase. Commit per task.

Pre-existing test baseline: verify failures against branch base 490517fb; local npm test has ~21 known pre-existing failures suite-wide. Only NEW failures are blocking.

Execution-half boundary (do NOT cross into P4.7): the Zod schema validates SHAPE only — regex/enum/optionality. It does NOT check that toolSlug exists in any registry, that credentialRef points to a real credential row, or enforce write→approval. Those are runtime concerns in P4.7. Keep the schema pure.


File Structure

Create:

  • api/src/config-assets/schemas/tool-binding.schema.tsToolBindingContent Zod schema + ToolBindingContentT type.
  • api/src/config-assets/schemas/tool-binding.schema.spec.ts — schema unit tests (mirror kb-domain-rule.schema.spec.ts).
  • frontend/src/app/ops/config-assets/ToolBindingEditor.tsx — Ops editor (mirror KbDomainRuleEditor.tsx).

Modify:

  • api/src/config-assets/schemas/index.ts — import + one CONTENT_SCHEMAS line + update the "only remaining unimplemented" comment.
  • api/src/config-assets/schemas/index.spec.ts — the registry-keys assertion (4 → 5 keys).
  • frontend/src/app/ops/config-assets/[id]/page.tsx — import ToolBindingEditor + add assetType === "tool_binding" branch + remove tool_binding from the "not available yet" fallback list.
  • api/src/config-assets/CLAUDE.md — mark tool_binding storage-half shipped; add the schema to the schemas/ inventory.
  • docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §3 — correct the 3 divergences inline (so the design doc stops teaching the wrong field names).

Task 1: ToolBindingContent Zod schema (storage half)

Files:

  • Create: api/src/config-assets/schemas/tool-binding.schema.ts
  • Test: api/src/config-assets/schemas/tool-binding.schema.spec.ts

Decision on allowedOps: constraints.allowedOps is a list of operation identifiers (e.g. orders.read) consumed by the P4.7 ActionPolicy gate — they are constrained identifiers, not operator prose, so a bare z.string().max(64) is correct (NOT opStr). Only genuinely operator-visible free-text fields need opStr/NFR5; tool_binding has none (every field is a slug/enum/identifier/uuid/number). No budget superRefine needed — the content is tiny and bounded by the field maxes.

  • Step 1: Read the template + real interfaces

Read api/src/config-assets/schemas/kb-domain-rule.schema.ts (the pattern), api/src/agent-api/tool-registry.ts:1 (AgentToolKind = 'bespoke' | 'nango' — confirm no mcp), and api/src/config-assets/schemas/skill.schema.ts:47 (tools: z.array(z.string().regex(/^[a-z0-9_]{1,64}$/))toolSlug must use the identical regex so skills ∩ bindings aligns).

  • Step 2: Write the failing test tool-binding.schema.spec.ts
import { ToolBindingContent } from "./tool-binding.schema";

describe("ToolBindingContent", () => {
const valid = {
toolSlug: "shopify_query",
kind: "nango",
accessClass: "read",
constraints: { allowedOps: ["orders.read"], rateLimitPerHour: 100 },
credentialRef: "00000000-0000-0000-0000-0000000000c1",
};

test("accepts a valid binding", () => {
expect(ToolBindingContent.parse(valid)).toMatchObject({ toolSlug: "shopify_query", kind: "nango" });
});

test("constraints defaults to {} and credentialRef is optional", () => {
const parsed = ToolBindingContent.parse({ toolSlug: "order_lookup", kind: "bespoke", accessClass: "write" });
expect(parsed.constraints).toEqual({});
expect(parsed.credentialRef).toBeUndefined();
});

test("rejects toolSlug with illegal chars", () => {
expect(() => ToolBindingContent.parse({ ...valid, toolSlug: "Bad Slug!" })).toThrow();
});

test("rejects an unknown kind (mcp is NOT a valid kind yet — P4.6)", () => {
expect(() => ToolBindingContent.parse({ ...valid, kind: "mcp" })).toThrow();
});

test("rejects an unknown accessClass", () => {
expect(() => ToolBindingContent.parse({ ...valid, accessClass: "admin" })).toThrow();
});

test("rejects a non-uuid credentialRef", () => {
expect(() => ToolBindingContent.parse({ ...valid, credentialRef: "not-a-uuid" })).toThrow();
});

test("rejects rateLimitPerHour over cap / non-positive", () => {
expect(() => ToolBindingContent.parse({ ...valid, constraints: { rateLimitPerHour: 0 } })).toThrow();
expect(() => ToolBindingContent.parse({ ...valid, constraints: { rateLimitPerHour: 100000 } })).toThrow();
});
});

Run: cd api && npx jest src/config-assets/schemas/tool-binding.schema.spec.ts -v → FAIL (module not found).

  • Step 3: Implement tool-binding.schema.ts
import { z } from "zod";

// R6.2 storage half. `kind` + enum align with the real AgentToolKind in
// api/src/agent-api/tool-registry.ts ('bespoke' | 'nango' — 'mcp' is a P4.6
// addition that does NOT exist in code yet, so it is intentionally absent
// here; adding it would desync the P4.7 registry cross-check). The runtime
// dispatch chain (tool_permissions × TOOL_CATALOG → AgentRun.toolsManifest)
// and the exposure/approval gate are P4.7 — this schema validates SHAPE only:
// it does NOT verify toolSlug exists in any registry or that credentialRef
// points to a real credential row.
export const ToolBindingContent = z.object({
// Same regex as skill.schema.ts `tools[]` so `skills ∩ bindings` (P3/P4.7)
// intersects by slug string.
toolSlug: z.string().regex(/^[a-z0-9_]{1,64}$/),
kind: z.enum(["bespoke", "nango"]),
accessClass: z.enum(["read", "write"]), // write ⇒ P4.7 routes each call to Expert approval (R6.4)
constraints: z
.object({
// Operation identifiers (e.g. "orders.read") — constrained tokens, not
// operator prose, so bare string is correct (no NFR5 opStr needed).
allowedOps: z.array(z.string().max(64)).max(64).optional(),
rateLimitPerHour: z.number().int().positive().max(10_000).optional(),
})
.default({}),
// References an integration_credentials row at (org_id, integration_type)
// granularity (entities.ts @Unique(["orgId","integrationType"])). The
// credential body NEVER enters content/prompt/trace (R6 AC3). Existence +
// resolution are validated in P4.7, not here.
credentialRef: z.string().uuid().optional(),
});

export type ToolBindingContentT = z.infer<typeof ToolBindingContent>;
  • Step 4: Run test → PASS (all 7). npx tsc --noEmit → clean.

  • Step 5: Commit (from the worktree root)

git add api/src/config-assets/schemas/tool-binding.schema.ts api/src/config-assets/schemas/tool-binding.schema.spec.ts
git commit -m "feat(config-assets): tool_binding Zod content schema (storage half, P2.5)"

Task 2: register tool_binding in CONTENT_SCHEMAS

Files:

  • Modify: api/src/config-assets/schemas/index.ts

  • Test: api/src/config-assets/schemas/index.spec.ts

  • Step 1: Read index.ts (the CONTENT_SCHEMAS object at ~:15-20 + the "only remaining unimplemented" comment ~:13) and index.spec.ts (find the assertion on Object.keys(CONTENT_SCHEMAS) — currently expects 4 keys).

  • Step 2: Update the failing test first

In index.spec.ts, change the registry-keys assertion to include tool_binding (5 keys). Also add an assertion that CONTENT_SCHEMAS.tool_binding is defined and parses a valid binding (mirror how the spec asserts other types are registered). Run → FAIL (tool_binding not registered yet).

  • Step 3: Register

In index.ts: import { ToolBindingContent } from "./tool-binding.schema"; and add tool_binding: ToolBindingContent, to CONTENT_SCHEMAS. Update the ~:13 comment (no longer "the only remaining unimplemented type" — now all 5 types have schemas; note the execution half of tool_binding is P4.7).

  • Step 4: Run npx jest src/config-assets/schemas/ -v → all PASS. npx tsc --noEmit → clean.

  • Step 5: Commit

git add api/src/config-assets/schemas/index.ts api/src/config-assets/schemas/index.spec.ts
git commit -m "feat(config-assets): register tool_binding in CONTENT_SCHEMAS (P2.5)"

Task 3: ToolBindingEditor Ops editor

Files:

  • Create: frontend/src/app/ops/config-assets/ToolBindingEditor.tsx

  • Modify: frontend/src/app/ops/config-assets/[id]/page.tsx

  • Step 1: Read the template + integration point

Read frontend/src/app/ops/config-assets/KbDomainRuleEditor.tsx fully (props contract: { initialContent, saving?, issues?, onSave }; useIssueMapping for per-field + form-level errors; get/asStringArray/splitLines from ./editorUtils; <select> for enums; optional fields OMITTED in handleSave not sent as ""/[]). Read [id]/page.tsx around the editor branches (:238-273) and imports (:30-33), and the fallback "not available yet" block that currently lists tool_binding.

  • Step 2: Implement ToolBindingEditor.tsx — mirror KbDomainRuleEditor exactly for structure. Fields:

    • toolSlug — text input (FormField + input), errorFor("toolSlug").
    • kind<select> with options bespoke / nango (match the schema enum EXACTLY — no mcp).
    • accessClass<select> with read / write.
    • constraints.allowedOps — textarea, one op per line, parsed via splitLines; OMIT from constraints if empty.
    • constraints.rateLimitPerHour — number input; OMIT if blank/NaN.
    • Build constraints object with only the present keys; if constraints ends up empty, still send {} (schema default) or omit (schema defaults it) — match how KbDomainRuleEditor handles its optional object.
    • credentialRef — text input (uuid); OMIT from payload if blank (else z.string().uuid() rejects "").
    • onSave(content) with the assembled object; <Button loading={saving}>.
    • Wire issues via useIssueMappingerrorFor per field + formLevelErrors.
  • Step 3: Wire into [id]/page.tsx

    • Add import { ToolBindingEditor } from "../ToolBindingEditor"; alongside the other editor imports.
    • Add a branch: {detail.assetType === "tool_binding" && (<ToolBindingEditor key={...} initialContent={detail.draftContent ?? detail.publishedContent} saving={saving} issues={issues} onSave={(content) => void handleSave(content)} />)} — copy the exact prop shape from the sibling branches.
    • Remove tool_binding from the fallback "not available yet" condition/list so it no longer falls through.
  • Step 4: Verify the frontend builds

Run the frontend typecheck/lint per the frontend's real commands (read frontend/package.json scripts — likely npm run lint and/or npm run build / tsc). At minimum run the lint + typecheck; a full next build if fast enough. Do NOT raise the lint max-warnings budget — fix any new warning at source (frontend lint has a hard budget; see the CI gates). Confirm no new warnings/errors.

  • Step 5: Commit
git add frontend/src/app/ops/config-assets/ToolBindingEditor.tsx frontend/src/app/ops/config-assets/[id]/page.tsx
git commit -m "feat(ops): tool_binding config-asset editor (P2.5)"

Task 4: documentation + design-doc correction

Files:

  • Modify: api/src/config-assets/CLAUDE.md

  • Modify: docs/superpowers/specs/2026-07-07-unified-config-asset-model.md §3

  • Step 1: Update config-assets/CLAUDE.md

    • The opening line lists tool_binding as "the only asset type still enumerated-but-unimplemented" — change to: tool_binding storage half shipped (Zod schema + registration + Ops editor); the execution half (chat-path exposure via tool_permissions × TOOL_CATALOG → AgentRun.toolsManifest, slug-exists validation, credential resolution, write→Expert approval) is P4.7.
    • In the schemas/ inventory line, add tool-binding.schema.ts (ToolBindingContent: toolSlug regex-aligned with skill tools[] / kind: bespoke|nango / accessClass: read|write / constraints{allowedOps[], rateLimitPerHour} / credentialRef uuid → integration_credentials (org,integration_type)).
  • Step 2: Correct the design doc 2026-07-07-unified-config-asset-model.md §3 (~:195-207) — fix the 3 divergences INLINE (leave a short note that they were corrected against real code @ 490517fb): sourceKindkind; enum ["bespoke","nango","mcp"]["bespoke","nango"] (mcp is P4.6, not present); credentialRef comment "ADR-030 OSA/Channel" → "integration_credentials at (org_id, integration_type)". Also fix the misleading "对齐 AGENT_TOOL_REGISTRY" note if present — the real runtime chain is TOOL_CATALOG/tool_permissions (AGENT_TOOL_REGISTRY is the legacy domain-mode static table). If §5 has a "five Zod schemas" checklist item, tick tool_binding.

  • Step 3: Self-check no machine paths

Run: grep -rnE '/Users/|/home/|/private/tmp' api/src/config-assets/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md; echo "exit=$?"exit=1.

  • Step 4: Commit
git add api/src/config-assets/CLAUDE.md docs/superpowers/specs/2026-07-07-unified-config-asset-model.md
git commit -m "docs: tool_binding storage half shipped + correct design-doc divergences (P2.5)"

Task 5: full verification

  • Step 1: cd api && npx tsc --noEmit && npm run lint → clean (no new warnings).
  • Step 2: npx jest src/config-assets/ --runInBand → all pass (schema + registry + no regression).
  • Step 3: Frontend: run its lint + typecheck (real commands from frontend/package.json) → clean.
  • Step 4: No commit — verification only. Proceed to Phase-level review.

Self-Review Checklist

Spec coverage (corrected):

  • tool_binding Zod schema, kind (not sourceKind), enum without mcp → Task 1 ✅
  • CONTENT_SCHEMAS registration → Task 2 ✅
  • Ops editor mirroring the pattern → Task 3 ✅
  • 3 design divergences corrected in the design doc → Task 4 ✅
  • credentialRef = (org, integration_type) semantics → Task 1 comment + Task 4 doc ✅
  • NO execution half (slug-exists / credential resolution / write-approval) → explicitly deferred to P4.7 ✅

Type consistency: ToolBindingContent/ToolBindingContentT defined Task 1, imported Task 2. toolSlug regex identical to skill tools[]. Editor enum options must match schema enums exactly (bespoke/nango, read/write).

Boundary discipline: schema validates shape only; no registry/credential lookups (those are P4.7). No migration, no controller change (tool_binding already in enum + CHECK + DTO).