Skip to content

Wire a tool into the schema-driven AI Tools panel

The “AI Tools” panel in saas-2.0 is not hand-authored. There is no per-tool React component, no form JSX, no config file you have to edit. The panel is built at runtime from each tool’s registered Pydantic JSON Schema — the same input_schema_json the agent plans against. Ship a tool worker with a typed input model and it shows up in the panel with a working form. That is the whole integration: there is no frontend step.

The schema makes six hops from your Python model to a rendered control. Every link is a committed, verifiable path — nothing is inferred or guessed:

# Where What happens
1 Worker BaseTool.input_schema_json() returns json.dumps(self.input_model.model_json_schema()) and rides RegisterTool on boot.
2 generation.tools row Stored as the input_schema jsonb column (Tool.input_schema_json, field 7). Re-register upserts ON CONFLICT (environment, model, operation).
3 GetActiveTools The Go data service returns active rows for the environment, cached under tools:active:{environment}. RegisterTool invalidates that key, so a reboot refreshes the panel.
4 Gateway ListTools core-gateway-consumer projects each internal Tool down to a consumer-safe ToolView and forwards input_schema_json verbatim.
5 BFF GET /api/tools The Next.js route proxies GenerationService.ListTools and returns the ToolView rows as JSON.
6 Panel parseToolSchema() turns the JSON Schema into panel controls; buildPayload() coerces the entered values back to the Pydantic-typed payload for createGeneration.

The two protos that define the contract:

  • Registry sideGenerationToolDataService in generation/tool/v1/tool.proto. Tool carries input_schema_json (field 7, db_column "input_schema") and output_schema_json (field 8). GetActiveTools is require_internal_auth, target_db = "prod", cached 3600s under tools:active:{environment}.
  • Consumer sideGenerationService.ListTools in consumer/v1/consumer.proto. ToolView exposes only id, name, operation, model, description, cost_unit, input_schema_jsonno credentials, pricing, or rate-limit internals. The response is a flat repeated list; grouping is a UI concern, never in the proto.

The gateway projection is a one-liner — mappers.py::tool_view copies input_schema_json=tool.input_schema_json, and servicers/generation.py::ListTools calls GetActiveTools with the gateway’s own environment.

src/panels/schema.ts (parseToolSchema) walks the schema’s properties and maps each one to a control. It resolves $ref / allOf / anyOf (so X | None optionals and Pydantic $defs enums work), then applies these rules:

JSON Schema shape Rendered control
x-sv-file-input marker (single or list item) Library file picker (submits the picked file id)
enum Select (numeric enums coerced back to numbers)
boolean Toggle
integer / number with minimum and maximum Slider (else a number input)
object (or nested properties) JSON textarea
array of enum Tag multi-select
array of objects JSON-array textarea
string whose name/description hints at a media file Upload
long / prompt-named string (prompt, lyrics, description, …) Multi-line prompt
any other string Text input

buildPayload() is the inverse: it coerces each entered value back to the Pydantic-expected JSON type before createGeneration — numbers to numbers, JSON textareas parsed, file pickers reduced to file id(s), empty optionals dropped.

  1. One card per operation. libraryScript.js::loadTools() fetches the registry via listToolsCached() (GET /api/tools) and groups the flat list by operation into a backendOps map — operation → { label, description, models[] }. Each operation becomes one card.

  2. Models become a dropdown. Tools that share an operation but differ by model (e.g. several song-gen versions) become the selectable Model dropdown inside DynamicToolPanel.tsx. Switching the model rebuilds the form from that tool’s schema.

  3. Submit runs a real generation. On submit, buildPayload produces the payload and the panel calls createGeneration({ toolId, projectId, workspaceId, payloadJson, license }), then dispatches a sv:generation-started event the app shell can open a live stream from.