Skip to main content
Prefab’s expression engine and action system cover most UI interactions declaratively. But some patterns — like sliders that sum to 100%, or domain-specific formatting — need custom client-side logic. Custom handlers let you write JavaScript functions that plug into the expression engine (as pipes) and the action dispatcher (as handlers), without breaking the protocol’s declarative boundary. Custom handlers are host-level configuration, not protocol. You write the JS as part of your app setup. The protocol references handlers by name only — the generated JSON never contains raw JavaScript.

Custom Pipes

Register formatting functions that become available in {{ }} expressions. Each pipe receives the current value and an optional colon-separated argument, just like built-in pipes. Drag the slider to see the stars pipe update live: The tempColor pipe adds contextual emoji based on the value: The initials pipe extracts first letters. Type a name to see it update: Custom pipes follow the same rules as built-in ones — they’re synchronous, pure functions. Built-in pipes always take priority, so you can’t accidentally shadow number or currency.

Custom Action Handlers

Register functions that transform state in response to events. Handlers receive a context object with the current state snapshot and the triggering event, and return an object of state updates to merge.
from prefab_ui.app import PrefabApp
from prefab_ui.actions import CallHandler
from prefab_ui.components import Slider

app = PrefabApp(
    js_actions={
        "constrainBudget": """(ctx) => {
            const keys = ['infra', 'people', 'tools'];
            const changed = ctx.arguments.key;
            const newVal = ctx.event;
            const others = keys.filter(k => k !== changed);
            const remaining = 100 - newVal;
            const otherTotal = others.reduce((s, k) => s + ctx.state[k], 0);
            const updates = {};
            for (const k of others) {
                updates[k] = otherTotal > 0
                    ? Math.round((ctx.state[k] / otherTotal) * remaining)
                    : Math.round(remaining / others.length);
            }
            return updates;
        }""",
    },
    ...
)

# In your UI — pass the slider's key so the handler knows which changed:
Slider(
    name="infra",
    on_change=CallHandler("constrainBudget", arguments={"key": "infra"}),
)
The handler context has three fields:
FieldDescription
ctx.stateSnapshot of the full state at the time the handler fires. Read-only — return updates instead of mutating.
ctx.eventThe $event value from the triggering interaction (slider value, click event, etc.).
ctx.argumentsOptional extra arguments from the CallHandler action spec.
The return value is merged into state using the same mechanism as SetState. If the handler returns nothing (void/undefined), no state changes happen.

CallHandler Action

CallHandler is the action that invokes a registered handler. It mirrors CallTool — where CallTool calls a server-side MCP tool, CallHandler calls a client-side JavaScript function.
from prefab_ui.actions import CallHandler

# Basic usage
Button(on_click=CallHandler("refresh"))

# With extra arguments
Button(on_click=CallHandler(
    "processData",
    arguments={"format": "csv"},
))

# Chained with other actions
Button(on_click=[
    SetState("loading", True),
    CallHandler("validate"),
])
The handler’s return value is available as $result in onSuccess callbacks, just like CallTool:
Button(on_click=CallHandler(
    "compute",
    on_success=SetState("result", "{{ $result.total }}"),
    on_error=ShowToast("Computation failed", variant="error"),
))

Example: Linked Sliders

Three budget sliders constrained to sum to 100%. Moving one redistributes the others proportionally. This is the kind of client-side coordination that expressions can’t do — you need a function that knows about all the sliders and can compute the redistribution.
from prefab_ui.app import PrefabApp
from prefab_ui.actions import CallHandler
from prefab_ui.components import Card, CardContent, Column, Row, Slider, Text


with Card():
    with CardContent():
        with Column(gap=4):
            for label, key in [
                ("Infrastructure", "infra"),
                ("People", "people"),
                ("Tools", "tools"),
            ]:
                with Column(gap=1):
                    with Row(css_class="justify-between"):
                        Text(label)
                        Text(f"{{{{ {key} | round }}}}%", bold=True)
                    Slider(
                        name=key, max=100, step=1,
                        on_change=CallHandler(
                            "constrain",
                            arguments={"key": key},
                        ),
                    )

            with Row(css_class="justify-between pt-4 border-t"):
                Text("Total", bold=True)
                Text("{{ infra + people + tools | round }}%", bold=True)

app = PrefabApp(
    js_actions={
        "constrain": """(ctx) => {
            const keys = ['infra', 'people', 'tools'];
            const changed = ctx.arguments.key;
            const newVal = ctx.event;
            const others = keys.filter(k => k !== changed);
            const remaining = 100 - newVal;
            const otherTotal = others.reduce((s, k) => s + ctx.state[k], 0);
            const updates = {};
            for (const k of others) {
                updates[k] = otherTotal > 0
                    ? Math.round((ctx.state[k] / otherTotal) * remaining)
                    : Math.round(remaining / others.length);
            }
            return updates;
        }""",
    },
)
Run it with uv run python examples/linked_sliders.py to see it in action. The handler runs client-side on every drag — no server round-trip, no lag.

API Reference

PrefabApp Fields

js_pipes
dict[str, str] | None
default:"None"
Custom pipe functions. Keys are pipe names, values are JavaScript function expressions. Each function receives (value, arg?) and returns the transformed value.
js_actions
dict[str, str] | None
default:"None"
Custom action handlers. Keys are handler names, values are JavaScript function expressions. Each function receives (ctx) with {state, event, arguments} and returns state updates to merge.

CallHandler Parameters

handler
str
required
Name of the registered handler function.
arguments
dict | None
default:"None"
Extra arguments passed to the handler via ctx.arguments. Supports template expressions.