Writing

How to Write a Genkit Python Plugin from Scratch

Zero guides exist on how to write one. This is that guide.

How to Write a Genkit Python Plugin from Scratch

The plugin system is how Genkit grows. Every model provider, every new action type, every cross-cutting middleware—it all flows through plugins. Google ships the first-party ones (Google AI, Vertex AI, Anthropic, Ollama). The extension point exists for everyone else.

Zero guides exist on how to write one. This is that guide.

We’ll build a weather plugin from scratch: a Plugin subclass that wraps a REST API and exposes it as both a tool (callable by LLMs) and a flow (callable from your application). By the end you’ll understand the full plugin lifecycle and be ready to build your own.


The Minimal Plugin Shell

Every Genkit plugin is a class that extends Plugin. The base class has three required methods:

from genkit.plugin_api import Action, ActionKind, ActionMetadata, Plugin
from genkit._core._typing import ActionMetadata

class MyPlugin(Plugin):
    name = "myplugin"  # namespace prefix for all actions this plugin registers
    
    async def init(self) -> list[Action]:
        """Called once at startup. Return actions to pre-register."""
        return []
    
    async def resolve(self, action_type: ActionKind, name: str) -> Action | None:
        """Called lazily when an action is needed. Return None if not found."""
        return None
    
    async def list_actions(self) -> list[ActionMetadata]:
        """Return action metadata for the Dev UI."""
        return []

That’s it. That’s a valid plugin. Pass it to Genkit:

from genkit import Genkit

ai = Genkit(plugins=[MyPlugin()])

Nothing useful happens yet—but the framework accepts it, initializes it, and queries it for actions when needed. Let’s fill it in.


Understanding the Three Methods

Before building the weather plugin, you need a clear mental model of when each method is called.

init() — Startup Registration

init() runs once, when the Genkit runtime initializes your plugin. Use it to pre-register actions you know exist at startup—especially when you need to make an API call to discover what’s available (like how GoogleAI calls client.models.list() during init).

Return a list of Action objects. Genkit registers each one under your plugin’s namespace.

async def init(self) -> list[Action]:
    # Fetch what's available at startup
    actions = []
    for endpoint in await self._discover_endpoints():
        action = Action(
            kind=ActionKind.TOOL,
            name=f"{self.name}/{endpoint.name}",
            fn=self._make_handler(endpoint),
            description=endpoint.description,
        )
        actions.append(action)
    return actions

resolve() — Lazy Action Creation

resolve() is called when your application requests an action that isn’t already in the registry. This is how GoogleAI handles model requests: it doesn’t pre-register every model at startup; it creates model Actions on demand.

Return None if you can’t resolve the requested action. Return an Action if you can.

async def resolve(self, action_type: ActionKind, name: str) -> Action | None:
    if action_type == ActionKind.MODEL and name.startswith(f"{self.name}/"):
        model_name = name[len(f"{self.name}/"):]
        return self._create_model_action(model_name)
    return None

list_actions() — Dev UI Discovery

list_actions() returns metadata about all actions your plugin can provide. This is what populates the Dev UI’s action browser. It doesn’t need to instantiate the actions—just describe them.

async def list_actions(self) -> list[ActionMetadata]:
    return [
        ActionMetadata(
            name=f"{self.name}/get-weather",
            action_type=ActionKind.TOOL,
            description="Get current weather for a city",
        ),
        ActionMetadata(
            name=f"{self.name}/weather-report",
            action_type=ActionKind.FLOW,
            description="Generate a full weather report",
        ),
    ]

Plugin Configuration with Pydantic

Real plugins need configuration: API keys, base URLs, timeout settings. Use Pydantic for this—it gives you validation, default values, and clear error messages.

from pydantic import BaseModel, Field

class WeatherPluginConfig(BaseModel):
    """Configuration for the Weather plugin."""
    api_key: str = Field(description="API key for weather service")
    base_url: str = Field(
        default="https://api.weatherapi.com/v1",
        description="Base URL for weather API"
    )
    timeout_seconds: float = Field(default=10.0, ge=0.1, le=60.0)
    default_units: str = Field(default="metric", pattern="^(metric|imperial)$")

class WeatherPlugin(Plugin):
    name = "weather"
    
    def __init__(self, config: WeatherPluginConfig | None = None, **kwargs):
        """Accept config as Pydantic model or keyword args."""
        if config is None:
            config = WeatherPluginConfig(**kwargs)
        self._config = config

Usage:

# Either way works
ai = Genkit(plugins=[
    WeatherPlugin(api_key="your-key"),
])

# Or explicit config object
ai = Genkit(plugins=[
    WeatherPlugin(config=WeatherPluginConfig(
        api_key="your-key",
        default_units="imperial",
    ))
])

This is the pattern first-party plugins use. GoogleAI.__init__ takes api_key, credentials, http_options, etc. and stores them on the instance. The plugin constructor is your only chance to capture configuration before init() is called.


Building the Weather Plugin: Tools

Tools in Genkit are Action objects with ActionKind.TOOL. They need typed inputs (Pydantic BaseModel) and a string-compatible return value. The LLM calls them by name; Genkit handles the schema generation and function dispatch.

Here’s the tool implementation for the weather plugin:

import httpx
from pydantic import BaseModel
from genkit.plugin_api import Action, ActionKind

class GetWeatherInput(BaseModel):
    city: str
    units: str = "metric"  # "metric" or "imperial"

class WeatherData(BaseModel):
    city: str
    temperature: float
    condition: str
    humidity: int
    units: str

class WeatherPlugin(Plugin):
    name = "weather"
    
    def __init__(self, api_key: str, base_url: str = "https://api.weatherapi.com/v1"):
        self._api_key = api_key
        self._base_url = base_url
    
    def _make_get_weather_action(self) -> Action:
        """Create the get-weather tool action."""
        api_key = self._api_key
        base_url = self._base_url
        
        async def get_weather_fn(input: GetWeatherInput) -> str:
            """Get current weather for a city."""
            async with httpx.AsyncClient() as client:
                response = await client.get(
                    f"{base_url}/current.json",
                    params={
                        "key": api_key,
                        "q": input.city,
                        "aqi": "no",
                    },
                    timeout=10.0,
                )
                response.raise_for_status()
                data = response.json()
                
                temp_key = "temp_c" if input.units == "metric" else "temp_f"
                unit_label = "°C" if input.units == "metric" else "°F"
                
                current = data["current"]
                location = data["location"]
                
                return (
                    f"{location['name']}: {current[temp_key]}{unit_label}, "
                    f"{current['condition']['text']}, "
                    f"humidity {current['humidity']}%"
                )
        
        return Action(
            kind=ActionKind.TOOL,
            name=f"{self.name}/get-weather",
            fn=get_weather_fn,
            description="Get current weather conditions for a city.",
        )
    
    async def init(self) -> list[Action]:
        return [
            self._make_get_weather_action(),
        ]

A few things to notice:

The fn closure captures config. api_key and base_url are captured in the closure, not stored on the action object. This is idiomatic Python—actions are stateless functions from Genkit’s perspective.

The function signature drives schema generation. Genkit introspects get_weather_fn’s type annotations and builds a JSON schema for the LLM automatically. The input must be a BaseModel—bare primitives (city: str) cause Gemini to reject the schema with a 400 error.

The return type is str. Tools can return strings directly. Genkit serializes the result for the LLM. You can also return Pydantic models or dicts.


Building the Weather Plugin: Flows

Flows are higher-level than tools. They’re user-facing entrypoints that can orchestrate multiple model calls and tool uses. From the LLM’s perspective, flows don’t exist—they’re callable from application code, not by the model.

A flow action uses ActionKind.FLOW:

from genkit import Genkit
from genkit._core._action import ActionRunContext, get_current_context

class WeatherPlugin(Plugin):
    name = "weather"
    
    def __init__(self, ai: Genkit, api_key: str, base_url: str = "https://api.weatherapi.com/v1"):
        self._ai = ai  # reference to the Genkit instance for generate() calls
        self._api_key = api_key
        self._base_url = base_url
    
    def _make_weather_report_flow(self, get_weather_action: Action) -> Action:
        """Create the weather-report flow action."""
        ai = self._ai
        
        class WeatherReportInput(BaseModel):
            city: str
            include_forecast: bool = False
        
        async def weather_report_fn(input: WeatherReportInput, ctx: ActionRunContext) -> str:
            """Generate a full weather report with analysis."""
            # First, get the raw weather data
            weather_data = await get_weather_action(
                GetWeatherInput(city=input.city)
            )
            
            # Then, use LLM to generate a human-friendly report
            prompt = f"""
            Weather data for {input.city}: {weather_data}
            
            Write a friendly 2-sentence weather report suitable for a general audience.
            {"Include what to wear and activity suggestions." if input.include_forecast else ""}
            """
            
            response = await ai.generate(prompt=prompt)
            return response.text
        
        return Action(
            kind=ActionKind.FLOW,
            name=f"{self.name}/weather-report",
            fn=weather_report_fn,
            description="Generate a human-friendly weather report for a city.",
        )
    
    async def init(self) -> list[Action]:
        get_weather = self._make_get_weather_action()
        weather_report = self._make_weather_report_flow(get_weather)
        return [get_weather, weather_report]

One architectural note: the flow needs a reference to the Genkit instance to call ai.generate(). This creates a circular dependency if you’re constructing both at the same time. The common solution is to pass the Genkit instance into the plugin constructor—the Genkit object is created first, then the plugin is initialized after.

Alternatively, you can use get_current_context() and ai.generate() from within the action fn closure, and pass ai at plugin construction time (before Genkit.__init__ is complete). The Genkit framework calls plugin.init() lazily during the first request, not synchronously during Genkit.__init__, so this works.


How resolve() and list_actions() Complete the Picture

The init() approach pre-registers everything at startup. That’s fine for a small, fixed set of actions. If your plugin wraps a dynamic API with hundreds of endpoints (like GoogleAI with its model catalog), use resolve() instead:

async def resolve(self, action_type: ActionKind, name: str) -> Action | None:
    """Lazily create actions by name, on demand."""
    prefix = f"{self.name}/"
    if not name.startswith(prefix):
        return None
    
    local_name = name[len(prefix):]
    
    if action_type == ActionKind.TOOL and local_name == "get-weather":
        return self._make_get_weather_action()
    
    if action_type == ActionKind.FLOW and local_name == "weather-report":
        get_weather = self._make_get_weather_action()
        return self._make_weather_report_flow(get_weather)
    
    return None

async def list_actions(self) -> list[ActionMetadata]:
    """Advertise actions to the Dev UI without instantiating them."""
    return [
        ActionMetadata(
            name=f"{self.name}/get-weather",
            action_type=str(ActionKind.TOOL),
            description="Get current weather conditions for a city.",
        ),
        ActionMetadata(
            name=f"{self.name}/weather-report",
            action_type=str(ActionKind.FLOW),
            description="Generate a human-friendly weather report.",
        ),
    ]

The Dev UI calls list_actions() when it renders the action browser. resolve() is called when your application actually invokes an action. If you only implement init(), the Dev UI shows what’s registered; if you implement resolve() and list_actions(), the Dev UI shows everything your plugin can provide, whether it’s been used yet or not.


How First-Party Plugins Do It

Let’s look at GoogleAI for patterns worth borrowing. Its key structure (py/plugins/google-genai/src/genkit/plugins/google_genai/google.py):

class GoogleAI(Plugin):
    name = "googleai"    # all models namespaced as "googleai/..."
    _vertexai = False
    
    def __init__(self, api_key=None, ...):
        # Store config at construction time
        self._client_kwargs = {"api_key": api_key, ...}
        # Use loop_local_client for thread-safe client creation
        self._runtime_client = loop_local_client(
            lambda: genai.client.Client(**self._client_kwargs)
        )
    
    async def init(self) -> list[Action]:
        # Discover models from API
        genai_models = _list_genai_models(self._runtime_client(), is_vertex=False)
        actions = []
        for name in genai_models.gemini:
            actions.append(self._resolve_model(f"googleai/{name}"))
        return actions
    
    async def resolve(self, action_type, name) -> Action | None:
        # On-demand action creation for unknown model names
        if action_type == ActionKind.MODEL:
            return self._resolve_model(name)
        return None

Three patterns to borrow:

1. loop_local_client for SDK clients. If you’re wrapping an SDK that creates event loop-bound resources (HTTP clients, gRPC channels), use loop_local_client from genkit.plugin_api. It creates one instance per event loop, which matters in test environments where multiple loops are created and destroyed.

from genkit.plugin_api import loop_local_client
import httpx

class MyPlugin(Plugin):
    def __init__(self, api_key: str):
        self._client = loop_local_client(
            lambda: httpx.AsyncClient(
                headers={"X-API-Key": api_key},
                timeout=10.0
            )
        )
    
    async def init(self) -> list[Action]:
        client = self._client()  # get loop-local instance
        ...

2. Separate _resolve_* helpers. GoogleAI has _resolve_model(), _resolve_embedder(), _resolve_veo_model(). Each returns one Action. Both init() and resolve() call these helpers. This avoids duplicating action creation logic.

3. name class variable, not instance variable. The plugin name is a class-level string. Genkit uses it as the namespace prefix—all actions returned from init() have their names normalized to ensure they start with {plugin.name}/. You don’t have to manually prefix every action name, but being explicit (using f"{self.name}/my-action") makes the code clearer.


Testing with the Dev UI

The Dev UI (genkit start) auto-discovers your plugin’s actions via list_actions() and renders them in the action browser. Running locally:

# pyproject.toml
[project]
dependencies = [
    "genkit[google-genai] @ git+https://github.com/firebase/genkit.git#subdirectory=py/packages/genkit",
]
# app.py
from genkit import Genkit
from genkit.plugins.google_genai import GoogleAI
from weather_plugin import WeatherPlugin, GetWeatherInput

ai = Genkit(
    plugins=[
        GoogleAI(),
        WeatherPlugin(ai=..., api_key="your-weather-key"),
    ],
    model="googleai/gemini-2.0-flash",
)

# Make the tool available to the LLM for interactive testing
@ai.flow()
async def weather_assistant(question: str) -> str:
    # The weather plugin's tool is registered; the LLM can call it
    response = await ai.generate(
        prompt=question,
        tools=["weather/get-weather"],  # reference by name
    )
    return response.text

if __name__ == "__main__":
    ai.run_main(main())

Run genkit start -- python app.py and open the Dev UI. You’ll see your plugin’s tool and flow in the action browser. You can invoke them directly from the UI to test inputs, inspect outputs, and view traces—without writing test harnesses.


Complete Example: The Weather Plugin

Here’s the full weather plugin, production-ready. Note the architecture decision: the plugin registers tools only. Flows that orchestrate model calls are defined by application code, not by the plugin. This avoids the circular dependency of a plugin needing a reference to the Genkit instance that contains it.

# weather_plugin.py

import httpx
from pydantic import BaseModel, Field
from genkit.plugin_api import Action, ActionKind, ActionMetadata, loop_local_client, Plugin

class WeatherPluginConfig(BaseModel):
    api_key: str
    base_url: str = "https://api.weatherapi.com/v1"
    timeout: float = Field(default=10.0, ge=0.1, le=60.0)

class GetWeatherInput(BaseModel):
    city: str
    units: str = Field(default="metric", pattern="^(metric|imperial)$")

class GetForecastInput(BaseModel):
    city: str
    days: int = Field(default=3, ge=1, le=7)

class WeatherPlugin(Plugin):
    name = "weather"
    
    def __init__(self, config: WeatherPluginConfig | None = None, **kwargs):
        if config is None:
            config = WeatherPluginConfig(**kwargs)
        self._config = config
        # Thread-safe, loop-local HTTP client
        self._http = loop_local_client(
            lambda: httpx.AsyncClient(
                base_url=config.base_url,
                timeout=config.timeout,
            )
        )
    
    def _make_get_weather_action(self) -> Action:
        config = self._config
        http = self._http
        
        async def get_weather(input: GetWeatherInput) -> str:
            """Get current weather conditions for a city."""
            client = http()
            response = await client.get(
                "/current.json",
                params={"key": config.api_key, "q": input.city, "aqi": "no"},
            )
            response.raise_for_status()
            data = response.json()
            
            temp_key = "temp_c" if input.units == "metric" else "temp_f"
            unit = "°C" if input.units == "metric" else "°F"
            loc = data["location"]["name"]
            cur = data["current"]
            
            return (
                f"{loc}: {cur[temp_key]}{unit}, "
                f"{cur['condition']['text']}, "
                f"humidity {cur['humidity']}%"
            )
        
        return Action(
            kind=ActionKind.TOOL,
            name=f"{self.name}/get-weather",
            fn=get_weather,
            description="Get current weather conditions for any city worldwide.",
        )
    
    def _make_get_forecast_action(self) -> Action:
        config = self._config
        http = self._http
        
        async def get_forecast(input: GetForecastInput) -> str:
            """Get multi-day weather forecast for a city."""
            client = http()
            response = await client.get(
                "/forecast.json",
                params={
                    "key": config.api_key,
                    "q": input.city,
                    "days": input.days,
                },
            )
            response.raise_for_status()
            data = response.json()
            
            days = data["forecast"]["forecastday"]
            lines = []
            for day in days:
                d = day["day"]
                lines.append(
                    f"{day['date']}: {d['avgtemp_c']}°C avg, "
                    f"{d['condition']['text']}"
                )
            return "\n".join(lines)
        
        return Action(
            kind=ActionKind.TOOL,
            name=f"{self.name}/get-forecast",
            fn=get_forecast,
            description="Get a multi-day weather forecast for any city.",
        )
    
    async def init(self) -> list[Action]:
        return [
            self._make_get_weather_action(),
            self._make_get_forecast_action(),
        ]
    
    async def resolve(self, action_type: ActionKind, name: str) -> Action | None:
        local = name.removeprefix(f"{self.name}/")
        if action_type == ActionKind.TOOL:
            if local == "get-weather":
                return self._make_get_weather_action()
            if local == "get-forecast":
                return self._make_get_forecast_action()
        return None
    
    async def list_actions(self) -> list[ActionMetadata]:
        return [
            ActionMetadata(
                name=f"{self.name}/get-weather",
                action_type=str(ActionKind.TOOL),
                description="Get current weather conditions for any city worldwide.",
            ),
            ActionMetadata(
                name=f"{self.name}/get-forecast",
                action_type=str(ActionKind.TOOL),
                description="Get a multi-day weather forecast for any city.",
            ),
        ]

Using it in an application:

# app.py

from genkit import Genkit
from genkit.plugins.google_genai import GoogleAI
from weather_plugin import WeatherPlugin

# Pass the plugin in the constructor
ai = Genkit(
    plugins=[
        GoogleAI(),
        WeatherPlugin(api_key="your-weather-api-key"),
    ],
    model="googleai/gemini-2.0-flash",
)

# Application-level flows use the plugin's registered tools
@ai.flow()
async def travel_advisor(destination: str) -> str:
    """Should I visit this city this weekend? Uses real weather data."""
    response = await ai.generate(
        prompt=f"Should I visit {destination} this weekend? Consider the weather.",
        tools=["weather/get-weather", "weather/get-forecast"],
    )
    return response.text

@ai.flow()
async def weather_report(city: str) -> str:
    """Full weather report with analysis."""
    # Call the tool action directly to get structured data
    from weather_plugin import GetWeatherInput, GetForecastInput
    
    # Tools are registered Actions; resolve and call them
    weather_action = await ai.registry.resolve_action(
        ActionKind.TOOL, "weather/get-weather"
    )
    current = await weather_action(GetWeatherInput(city=city))
    
    response = await ai.generate(
        prompt=f"Write a friendly 2-sentence weather report. Data: {current}"
    )
    return response.text

async def main():
    result = await travel_advisor("Chicago")
    print(result)
    
    report = await weather_report("Tokyo")
    print(report)

if __name__ == "__main__":
    ai.run_main(main())

What Plugins Can Register

The ActionKind enum shows everything the registry understands:

  • ActionKind: MODEL · What it is: Language model (text/multimodal generation)
  • ActionKind: EMBEDDER · What it is: Text embedding model
  • ActionKind: TOOL · What it is: Callable by LLMs during generation
  • ActionKind: FLOW · What it is: User-facing orchestration function
  • ActionKind: EVALUATOR · What it is: Flow/model evaluation function
  • ActionKind: RETRIEVER · What it is: Document retrieval for RAG
  • ActionKind: INDEXER · What it is: Document indexing for RAG
  • ActionKind: CUSTOM · What it is: Arbitrary registered function

Your plugin can register any of these. The pattern is the same: create an Action with the appropriate kind and a function that matches the expected signature for that kind.

For model actions specifically, use ai.define_model() rather than creating Action objects directly—it handles model-specific schema setup and wraps the response type correctly. For tools and flows, creating Action objects directly (as shown above) is the standard approach.


What to Expect as the SDK Matures

The plugin API is functional and used by all first-party plugins. But it’s not yet stable in the semver sense. A few things that may change before 1.0:

  • Plugin construction pattern: Passing ai to the plugin constructor is a common need but not yet a first-class feature. Expect a cleaner API here.
  • MiddlewarePlugin subclass: If your plugin only adds middleware (not models or tools), use MiddlewarePlugin instead of Plugin—it removes the need to implement init(), resolve(), and list_actions().
  • loop_local_client: This function is in genkit.plugin_api and stable, but the import path may be reorganized.

The plugin system is one of the most powerful and least documented parts of Genkit Python. Building on it now means building ahead of the documentation—but the source is clean, the interface is simple, and the patterns from first-party plugins give you everything you need.


Jeff Huang is Tech Lead for Genkit Python at Google. The Genkit Python SDK is open source at github.com/firebase/genkit.