Adding Tools
Adding a new tool is the most common contribution to Sigilweaver. This guide walks through the complete process—both backend (where the transformation happens) and frontend (where the UI lives).
Overview
A tool requires changes in two places:
| Location | What | Why |
|---|---|---|
| Backend | BaseTool implementation | Actual data transformation |
| Frontend | Tool definition + config panel | UI and user configuration |
The type string must match exactly between them. If backend has @register_tool("MyTool"), frontend needs type: 'MyTool'.
Step 1: Backend Implementation
Create a new file in backend/app/domain/tools/implementations/:
# backend/app/domain/tools/implementations/my_tool.py
from typing import Any
import polars as pl
from app.domain.tools.base import BaseTool
from app.domain.tools.registry import register_tool
from app.api.models.common import DataSchema
@register_tool("MyTool")
class MyTool(BaseTool):
"""Brief description of what this tool does."""
async def execute(
self,
config: dict[str, Any],
inputs: dict[str, pl.LazyFrame]
) -> dict[str, pl.LazyFrame]:
"""Execute the transformation."""
lf = inputs["input"] # Input from socket "input"
# Your transformation logic (keep it lazy!)
min_age = config.get("min_age", 0)
result = lf.filter(pl.col("age") > min_age)
return {"output": result} # Output to socket "output"
async def get_output_schema(
self,
config: dict[str, Any],
input_schemas: dict[str, DataSchema]
) -> dict[str, DataSchema]:
"""Return output schema without execution."""
# For filters, schema passes through unchanged
return {"output": input_schemas["input"]}
async def validate_config(self, config: dict[str, Any]) -> list[str]:
"""Validate configuration. Return error messages."""
errors = []
if config.get("min_age", 0) < 0:
errors.append("min_age cannot be negative")
return errors
Register the Tool
Add an import to backend/app/domain/tools/implementations/__init__.py:
from app.domain.tools.implementations.my_tool import MyTool
And add it to the __all__ list in the same file. The import triggers the @register_tool decorator, adding it to the registry.
Key Backend Rules
-
Keep it lazy. Never call
.collect()in tools. The executor handles materialization. -
Socket IDs are strings. Match them exactly with the frontend definition.
-
Schema propagation matters.
get_output_schema()enables column dropdowns in the UI without running the whole pipeline. -
Validate config. Return helpful error messages for invalid configurations.
Step 2: Frontend Definition
Add the tool to frontend/src/data/tools.ts:
export const TOOL_DEFINITIONS: ToolDefinition[] = [
// ... existing tools
{
type: 'MyTool', // Must match @register_tool("MyTool")
name: 'My Tool', // Display name in UI
icon: '🔧', // Emoji for now
category: 'preparation', // inout | preparation | join | aggregate
defaultConfig: {
min_age: 0,
},
sockets: [
{ id: 'input', direction: 'input' },
{ id: 'output', direction: 'output' },
],
},
];
Create a Config Panel
If your tool needs custom configuration UI, create a config panel:
// frontend/src/components/MyToolConfiguration.tsx
import { Tool } from '@/domain/workflow/entities/workflow';
interface Props {
tool: Tool;
onConfigChange: (config: Partial<Tool['config']>) => void;
}
export function MyToolConfig({ tool, onConfigChange }: Props) {
return (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium">Minimum Age</span>
<input
type="number"
value={tool.config.min_age ?? 0}
onChange={(e) => onConfigChange({ min_age: Number(e.target.value) })}
className="mt-1 block w-full rounded border px-3 py-2"
/>
</label>
</div>
);
}
Then register it in ConfigurationPanel.tsx:
case 'MyTool':
return <MyToolConfig tool={tool} onConfigChange={handleConfigChange} />;
Simple Tools
For tools with simple config (just a text input or toggle), you may not need a custom panel. The generic config panel can handle basic fields.
Step 3: Write Tests
Backend Tests
Create backend/tests/test_my_tool.py:
import pytest
import polars as pl
from app.domain.tools.implementations.my_tool import MyTool
@pytest.fixture
def sample_df():
return pl.LazyFrame({
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 35, 45]
})
@pytest.mark.asyncio
async def test_my_tool_filters_correctly(sample_df):
tool = MyTool()
config = {"min_age": 30}
result = await tool.execute(config, {"input": sample_df})
df = result["output"].collect()
assert len(df) == 2
assert set(df["name"].to_list()) == {"Bob", "Charlie"}
@pytest.mark.asyncio
async def test_my_tool_schema_passthrough(sample_df):
tool = MyTool()
input_schema = DataSchema(columns=[
ColumnInfo(name="name", dtype="String", nullable=True),
ColumnInfo(name="age", dtype="Int64", nullable=False),
])
result = await tool.get_output_schema(
{"min_age": 30},
{"input": input_schema}
)
assert result["output"] == input_schema
@pytest.mark.asyncio
async def test_my_tool_validates_config():
tool = MyTool()
errors = await tool.validate_config({"min_age": -5})
assert len(errors) == 1
assert "negative" in errors[0]
Frontend Tests
Create frontend/src/components/panels/config/__tests__/MyToolConfig.test.tsx:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MyToolConfig } from '../MyToolConfig';
describe('MyToolConfig', () => {
const mockTool = {
id: 'test-1',
type: 'MyTool',
x: 0,
y: 0,
config: { min_age: 10 },
sockets: [],
};
it('renders current value', () => {
render(<MyToolConfig tool={mockTool} onConfigChange={() => {}} />);
expect(screen.getByRole('spinbutton')).toHaveValue(10);
});
it('calls onConfigChange when value changes', () => {
const onConfigChange = vi.fn();
render(<MyToolConfig tool={mockTool} onConfigChange={onConfigChange} />);
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '25' } });
expect(onConfigChange).toHaveBeenCalledWith({ min_age: 25 });
});
});
Step 4: Verify Everything Works
# Backend tests
cd backend && pytest tests/test_my_tool.py -v
# Frontend tests
cd frontend && npm test MyToolConfig
# Full test suite
./scripts/test.sh
# Manual testing
./scripts/dev.sh
# Then drag your tool onto the canvas and verify it works
Common Patterns
Multiple Inputs
For tools like Join that take multiple inputs:
# Backend
sockets = [
{ id: 'left', direction: 'input' },
{ id: 'right', direction: 'input' },
{ id: 'output', direction: 'output' },
]
async def execute(self, config, inputs):
left_lf = inputs["left"]
right_lf = inputs["right"]
# Join logic...
Multiple Outputs
For tools like Filter that split data:
# Backend
sockets = [
{ id: 'input', direction: 'input' },
{ id: 'output-true', direction: 'output', type: 'T' },
{ id: 'output-false', direction: 'output', type: 'F' },
]
async def execute(self, config, inputs):
lf = inputs["input"]
true_result = lf.filter(condition)
false_result = lf.filter(~condition)
return {
"output-true": true_result,
"output-false": false_result,
}
Schema Changes
For tools that add or remove columns:
async def get_output_schema(self, config, input_schemas):
schema = input_schemas["input"]
# Add a new column
new_columns = schema.columns + [
ColumnInfo(name="new_col", dtype="Float64", nullable=True)
]
return {"output": DataSchema(columns=new_columns)}
Checklist
Before submitting a PR for a new tool:
- Backend implementation with
@register_tool - Registered in
register.py - Frontend definition in
tools.ts - Config panel (if needed)
- Backend tests
- Frontend tests (if config panel)
- Manual testing via UI
- All existing tests still pass
Next: Frontend Patterns or Backend Patterns for more detailed conventions.