4. Runnables & Transport

Expose capabilities through one declaration. The kernel generates REST, MCP, and CLI bindings.

The Gap

So far, other Python code calls checker.check() directly. But what if you want it callable via REST API? Or as an MCP tool? Or as a CLI command? Without the kernel, you’d write three implementations. With the kernel: declare it once.

@runnable — Schema Declaration

from pydantic import BaseModel
from signalpy.kernel import runnable

class CheckParams(BaseModel):
    text: str
    language: str = "EN"

@component("checker")
@requires(dictionaries=list[IDictionary])
class SpellChecker:
    @runnable("check", params=CheckParams, description="Check spelling")
    async def check_text(self, params):
        misspelled = [w for w in params.text.split()
                      if not self.by_language[params.language].check_word(w)]
        return {"misspelled": misspelled}
Note

@runnable declares a callable schema — a name ("check"), a typed param schema (CheckParams), and a description. Both def and async def work. Transport adapters discover these schemas via kernel.runnables() and call the handler directly.

Calling Other Components

Use @requires and direct method calls — no string-based dispatch:

@component("search-app")
@requires(checker=ISpellChecker)
class SearchApp:
    async def search(self, query: str):
        errors = await self.rt.checker.check_text(CheckParams(text=query))
        # ... use errors
Direct calls, not bus dispatch

Components call each other through @requires injection and direct method calls. The kernel wires dependencies; you call methods on the injected service. No string-based invoke() needed.

Transport Config on @component

To expose runnables via REST, MCP, or CLI, declare transport config directly on @component:

@component("checker",
           rest={"prefix": "/spell", "version": "v1"},  # → POST /api/v1/spell/check
           mcp={"name": "spell-tools"},                  # → MCP tool "checker.check"
           cli={"prefix": "spell"})                      # → spell check --text "hello"
@requires(dictionaries=list[IDictionary])
class SpellChecker:
    @runnable("check", params=CheckParams, description="Check spelling")
    async def check_text(self, params): ...
Write it once

The component never knows how it’s called. @runnable declares a schema. Transport config on @component tells transport adapters — components that render REST/MCP/CLI surfaces, covered in tutorial 5 — which runnables to expose. REST endpoints, MCP tools, and CLI commands are generated from the same declaration.

Native-Only Runnables

Some operations should be callable within the kernel but not exposed via external transports:

@runnable("_warmup", params=BaseModel, transports=["native"], description="Warm cache")
async def warmup(self, params): ...

transports=["native"] restricts the runnable to in-kernel callers only. Transport adapters skip it when building external surfaces.

Typed Params

Params are Pydantic models. They give you validation, defaults, auto-generated schemas, and attribute access (params.text, not params["text"]).