4. Runnables & API

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.

What “the bus” means

The bus is the kernel’s capability hub — a named dispatch table of callables ("checker.check", "config.get", …). Any component holding a reference to the kernel can invoke(name, params) by string name. @runnable is how you put a method on the bus; transport adapters (REST/MCP/CLI) read from the bus to expose it externally. Everything in this tutorial — and most of what follows — is about the bus.

@runnable

from pydantic import BaseModel
from signalpy.kernel import runnable

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

@component("checker")
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 puts a callable on the bus. It has a name ("check"), a typed param schema (CheckParams), and a description. Both def and async def work.

Bus Invocation

Any component (or the main script) can call it:

result = await kernel.bus.invoke("checker.check", {
    "text": "hello wrld", "language": "EN",
})
# → {"misspelled": ["wrld"]}

From inside a component, use self.rt.invoke() — same thing, but policy-checked.

@api — Transport Surfaces

To expose runnables via REST, MCP, or CLI:

@component("checker")
@api("rest", prefix="/spell", version="v1")   # → POST /api/v1/spell/check
@api("mcp", name="spell-tools")               # → MCP tool "checker.check"
@api("cli", prefix="spell")                   # → spell check --text "hello"
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 puts a capability on the bus. @api tells transport adapters — components that translate bus calls into REST/MCP/CLI traffic, covered in tutorial 5 — to expose it. REST endpoints, MCP tools, and CLI commands are generated from the same declaration.

Internal Runnables

Some operations should be on the bus but not exposed via API:

@runnable("_warmup", params=BaseModel, internal=True, description="Warm cache")
async def warmup(self, params): ...

internal=True keeps it off all API surfaces. Still callable via kernel.bus.invoke("checker._warmup").

Typed Params

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