Skip to content

Plugins

Plugins add new automation sources (trigger types) and new coding agents without changing Curupira. A plugin is an ordinary Python distribution that installs next to Curupira and declares one entry point per trigger or coding agent. Its automations are configured in the same TOML file, validated when the configuration loads, and run through the same scheduling, checkout, and coding-agent pipeline as the built-in triggers. Coding-agent plugins are covered in Agent plugins.

Plugins run in the Curupira process as soon as they are discovered, so install only distributions you trust.

Using a plugin

Install the plugin into the same environment as Curupira, then confirm that it loaded:

uv tool install curupira --with curupira-tickets
curu plugins list

curu plugins list prints each trigger type, the distribution that provides it, and the prompt placeholders it supplies, followed by one agent:<provider> line per coding agent with its distribution and executable. Configure the automation with that trigger_type plus the options documented by the plugin:

[coding_agents.automations.ops-tickets]
trigger_type = "ticket"
repo = "acme/api"
project = "OPS"
prompt = "Fix ${ticket_key} (${ticket_priority}) in ${repo}"

curu validate checks plugin options and placeholders exactly like built-in ones. If a plugin cannot be imported, every command that loads the configuration stops with a Configuration error that names the entry point and the distribution.

Writing a plugin

A plugin provides three things, all imported from curupira.plugins:

  1. A configuration model that extends AutomationConfigurationBase and gives trigger_type a default equal to the plugin's trigger type. It inherits repo, path, setup_script, checkout, prompt, and profile, and adds the plugin's own options as Pydantic fields and validators.
  2. A TaskSource that turns one poll into Task objects. Wrap it in PollingTaskFeed to get deduplication and backoff for free.
  3. A Trigger that ties the configuration model, prompt placeholders, and feed together.
"""Ticket tracker trigger for Curupira."""

from typing import Literal

from curupira.plugins import (
    AutomationConfigurationBase,
    FeedDependencies,
    NonEmptyString,
    PollingTaskFeed,
    ResolvedAutomation,
    Task,
    TaskFeed,
    TaskIdentity,
    TaskSource,
    Trigger,
)


class TicketAutomationConfiguration(AutomationConfigurationBase):
    """Discover tickets from one tracker project.

    Attributes:
        project: Tracker project key.
    """

    trigger_type: Literal["ticket"] = "ticket"
    project: NonEmptyString


class TicketSource(TaskSource):
    """List open tickets through the tracker's CLI."""

    async def discover(self, automation: ResolvedAutomation, limit: int) -> list[Task]:
        config = automation.configuration
        if not isinstance(config, TicketAutomationConfiguration):
            raise ValueError("ticket source requires a ticket configuration")
        tickets = ...  # call the tracker through FeedDependencies.runner
        return [
            Task(
                identity=TaskIdentity(
                    automation_id=automation.automation_id,
                    repo=config.repo,
                    task_type="ticket",
                    id=ticket.key,
                ),
                automation=automation,
                title=ticket.summary,
                body=ticket.description,
                url=ticket.url,
                attributes={"priority": ticket.priority},
            )
            for ticket in tickets[:limit]
        ]


class TicketTrigger(Trigger):
    """Run coding agents for tracker tickets."""

    trigger_type = "ticket"
    configuration_model = TicketAutomationConfiguration

    @classmethod
    def prompt_fields(cls) -> frozenset[str]:
        return frozenset({"ticket_key", "ticket_priority"})

    def prompt_context(self, task: Task) -> dict[str, str]:
        return {"ticket_key": task.identity.id, "ticket_priority": task.attributes["priority"]}

    def build_feed(
        self, automation: ResolvedAutomation, dependencies: FeedDependencies
    ) -> TaskFeed:
        return PollingTaskFeed(automation, dependencies.polling, TicketSource())

Register the trigger in the plugin's pyproject.toml. The entry point may name the Trigger subclass (instantiated without arguments) or a ready-made instance:

[project]
name = "curupira-tickets"
dependencies = ["curupira"]

[project.entry-points."curupira.triggers"]
ticket = "curupira_tickets:TicketTrigger"

Contract

Member Required Purpose
trigger_type Yes Value users write in trigger_type; must be unique across built-ins and plugins.
configuration_model Yes Pydantic model for the automation table.
prompt_fields() Yes Placeholders the trigger adds on top of the common ones.
prompt_context(task) Yes Values for those placeholders.
build_feed(automation, dependencies) Yes Feed that discovers tasks.
validate_task(task) No Rejects task snapshots the trigger could not produce.
create_version_control(runner) No Clone mechanism for the repositories; None keeps gh repo clone.
on_task_started(task, state) No Runs right before the coding agent starts.
on_task_finished(task, state) No Releases state after the agent exits; the default deletes the resumable session, so call super() when you override it.
api_version No Plugin API version the plugin targets; defaults to the running Curupira's PLUGIN_API_VERSION. Set it explicitly to fail fast on an incompatible Curupira release.

FeedDependencies exposes polling, state_db_path, and runner. Start external processes only through runner (an AsyncProcessRunner taking a CommandRequest), never through a shell. Put source-specific values in Task.attributes, a string mapping that is persisted with the task, so prompts still render after an interrupted task resumes.

Curupira rejects a plugin whose api_version differs from PLUGIN_API_VERSION, whose trigger_type is already registered, or whose configuration_model does not default trigger_type to the registered type.

Testing a plugin

Exercise the plugin without installing it by validating a configuration and calling the trigger directly:

from curupira.config import ApplicationSettings
from curupira.tasks.registry import register

register(TicketTrigger())
settings = ApplicationSettings.model_validate(
    {
        "coding_agents": {
            "automations": {
                "tickets": {
                    "trigger_type": "ticket",
                    "repo": "acme/api",
                    "project": "OPS",
                    "prompt": "Fix ${ticket_key}",
                }
            }
        }
    }
)

Curupira's own suite contains a complete example plugin in tests/plugins/ticket_plugin.py and the matching tests in tests/plugins/test_plugins.py.

Agent plugins

An agent plugin adds a coding-agent CLI that profiles select with provider. OpenCode, Codex, Claude Code, and Cursor are built in and register through the same registry, so a plugin provider behaves exactly like them: its profile options are validated when the configuration loads, and tasks run through the same scheduling, checkout, and session pipeline.

Agent plugins also run in the Curupira process as soon as they are discovered, so install only distributions you trust.

Using an agent plugin

Install the plugin next to Curupira, confirm that curu plugins list shows its agent:<provider> line, then point a profile at that provider:

[coding_agents.defaults]
profile = "echo"

[coding_agents.profiles.echo]
provider = "echo"
volume = "loud"

curu validate checks the profile with the plugin's own model. A plugin that cannot be imported, targets another plugin API version, or reuses a registered provider stops every command that loads the configuration with a Configuration error naming the entry point and the distribution.

Writing an agent plugin

An agent plugin provides two things, both imported from curupira.plugins:

  1. A profile model that extends CliProfileBase and gives provider a default equal to the plugin's provider. It inherits model and adds the CLI's own options as Pydantic fields and validators.
  2. A CodingAgentCliAdapter subclass that declares the provider metadata and translates a CodingTaskRequest into the native CLI's arguments.
"""Echo coding agent for Curupira."""

from typing import Literal

from curupira.plugins import CliProfileBase, CodingAgentCliAdapter, CodingTaskRequest


class EchoCliProfile(CliProfileBase):
    """Options for the echo CLI.

    Attributes:
        provider: Discriminator identifying the echo CLI.
        volume: How loudly the message is echoed.
    """

    provider: Literal["echo"] = "echo"
    volume: Literal["quiet", "loud"] = "quiet"


class EchoCliAdapter(CodingAgentCliAdapter):
    """Run tasks through the echo CLI."""

    executable = "echo-agent"
    provider = "echo"
    profile_model = EchoCliProfile
    display_name = "Echo Agent"
    install_url = "https://echo.example/"

    def build_arguments(self, request: CodingTaskRequest) -> tuple[str, ...]:
        profile = request.profile
        if not isinstance(profile, EchoCliProfile):
            raise ValueError("echo requires an echo profile")
        arguments = ["run", "--volume", profile.volume]
        if request.session_id is not None:
            arguments.extend(("--resume", request.session_id))
        return (*arguments, "--", request.message)

Register the adapter class in the plugin's pyproject.toml. Curupira instantiates it with its own process runner and reuses that instance for the provider's tasks:

[project]
name = "curupira-echo"
dependencies = ["curupira"]

[project.entry-points."curupira.agents"]
echo = "curupira_echo:EchoCliAdapter"

Agent contract

Member Required Purpose
provider Yes Value users write in a profile's provider; must be unique across built-ins and plugins.
executable Yes Native CLI started for each task.
profile_model Yes Pydantic model for the profile table; must extend CliProfileBase and default provider to the registered provider.
display_name Yes Name shown in the dashboard.
install_url Yes Where users install or learn about the CLI.
build_arguments(request) Yes Native argument vector for one task; resume with request.session_id when it is set.
session_id_from_line(line) No Returns the native session ID announced by one stdout line, or None; the default reads sessionID, session_id, or thread_id from a JSON event. Each distinct ID is reported once per run.
assigns_session_id No Set to True for CLIs that accept a caller-chosen session ID. On new runs Curupira generates a UUID, persists it before the process starts, and passes it as request.new_session_id; resumed runs keep request.session_id. Defaults to False.
render_output(output) No Extracts the final answer from captured stdout; the default understands the built-in JSONL formats. Override it when the CLI emits a different final-answer shape.
api_version No Plugin API version the plugin targets; defaults to the running Curupira's PLUGIN_API_VERSION. Set it explicitly to fail fast on an incompatible Curupira release.

Curupira starts the executable through AsyncProcessRunner with the argument vector from build_arguments, never through a shell, and reports native session identifiers found in the streamed JSONL output so interrupted tasks can resume. Curupira rejects an entry point that is not a CodingAgentCliAdapter subclass, whose api_version differs from PLUGIN_API_VERSION, whose provider is already registered, or whose profile_model does not default provider to the registered provider.

Curupira's own suite contains a complete example agent plugin in tests/plugins/echo_agent_plugin.py and the matching tests in tests/plugins/test_agent_plugins.py.

Reference

curupira.tasks.base.Trigger

Bases: ABC

Contract for a registered automation trigger type, built-in or plugin.

Attributes:

Name Type Description
trigger_type str

Value of trigger_type in the TOML that selects this trigger.

configuration_model type[AutomationConfigurationBase]

Pydantic model validating this trigger's automation table.

api_version int

Plugin API version the implementation was written against.

trigger_type class-attribute

configuration_model class-attribute

api_version = PLUGIN_API_VERSION class-attribute

prompt_fields() abstractmethod classmethod

Return placeholders supplied specifically by this trigger.

prompt_context(task) abstractmethod

Build values for this trigger's prompt placeholders.

build_feed(automation, dependencies) abstractmethod

Construct a task feed for a resolved automation.

validate_task(task)

Reject task snapshots that this trigger could not have produced.

create_version_control(runner)

Return a provider-specific clone mechanism, or None for the default gh.

on_task_started(task, state) async

Run after checkout, immediately before the coding agent starts.

on_task_finished(task, state) async

Release durable state once the coding agent exits.

curupira.tasks.base.FeedDependencies dataclass

Shared services required to construct task feeds.

Attributes:

Name Type Description
polling PollingSettings

Global discovery intervals and fetch limits.

gh GhClient

Authenticated GitHub CLI client.

az AzClient

Authenticated Azure CLI client.

cron CronScheduleRepository

Persistent cron schedule state.

state_db_path Path

SQLite database shared by all durable state.

runner AsyncProcessRunner

The only sanctioned way to start external processes.

polling instance-attribute

gh instance-attribute

az instance-attribute

cron instance-attribute

state_db_path instance-attribute

runner = field(default_factory=AsyncProcessRunner) class-attribute instance-attribute

__init__(polling, gh, az, cron, state_db_path, runner=AsyncProcessRunner())

curupira.tasks.base.TriggerState dataclass

Durable state available to trigger lifecycle hooks.

Attributes:

Name Type Description
sessions RunningSessionRepository

Running coding-session snapshots used for resumption.

cron CronScheduleRepository

Persistent cron schedule state.

sessions instance-attribute

cron instance-attribute

__init__(sessions, cron)

curupira.models.configuration.AutomationConfigurationBase pydantic-model

Bases: ValidatedModel

Shared options for one automation, keyed by its enclosing TOML table name.

Trigger plugins extend this model and give trigger_type a default equal to their registered type.

Attributes:

Name Type Description
trigger_type NonEmptyString

Registered trigger type selecting the configuration model.

repo NonEmptyString

Repository identifier whose format depends on the trigger type.

path Path | None

Optional base checkout path; relative paths are resolved from the TOML file.

setup_script str | None

Optional repository-relative script run after a fresh clone.

checkout Literal['worktree', 'main']

Whether tasks use isolated worktrees or the shared checkout.

prompt str

Template sent to the selected coding-agent CLI.

profile Identifier | None

Optional named CLI profile overriding the configured default.

Show JSON schema:
{
  "additionalProperties": false,
  "description": "Shared options for one automation, keyed by its enclosing TOML table name.\n\nTrigger plugins extend this model and give ``trigger_type`` a default equal to\ntheir registered type.\n\nAttributes:\n    trigger_type: Registered trigger type selecting the configuration model.\n    repo: Repository identifier whose format depends on the trigger type.\n    path: Optional base checkout path; relative paths are resolved from the TOML file.\n    setup_script: Optional repository-relative script run after a fresh clone.\n    checkout: Whether tasks use isolated worktrees or the shared checkout.\n    prompt: Template sent to the selected coding-agent CLI.\n    profile: Optional named CLI profile overriding the configured default.",
  "properties": {
    "trigger_type": {
      "minLength": 1,
      "title": "Trigger Type",
      "type": "string"
    },
    "repo": {
      "minLength": 1,
      "title": "Repo",
      "type": "string"
    },
    "path": {
      "anyOf": [
        {
          "format": "path",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Path"
    },
    "setup_script": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Setup Script"
    },
    "checkout": {
      "default": "worktree",
      "enum": [
        "worktree",
        "main"
      ],
      "title": "Checkout",
      "type": "string"
    },
    "prompt": {
      "title": "Prompt",
      "type": "string"
    },
    "profile": {
      "anyOf": [
        {
          "minLength": 1,
          "pattern": "^[A-Za-z0-9_-]+$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Profile"
    }
  },
  "required": [
    "trigger_type",
    "repo",
    "prompt"
  ],
  "title": "AutomationConfigurationBase",
  "type": "object"
}

Fields:

Validators:

trigger_type pydantic-field

repo pydantic-field

path = None pydantic-field

setup_script = None pydantic-field

checkout = 'worktree' pydantic-field

prompt pydantic-field

profile = None pydantic-field

model_config = ConfigDict(extra='forbid', frozen=True, validate_default=True) class-attribute instance-attribute

validate_setup_script(value) pydantic-validator

Require an optional repository-relative script path without traversal.

expand_path(value) pydantic-validator

Expand the optional user-relative workspace override.

validate_prompt_syntax(value) pydantic-validator

Reject empty prompts and invalid template placeholders.

curupira.agents.base.CodingAgentCliAdapter

Bases: ABC

Invoke a native CLI; agent definitions are owned by that external tool.

Attributes:

Name Type Description
executable str

Native CLI executable started for each task.

provider str

Value of provider in the TOML profile that selects this adapter.

profile_model type[CliProfileBase]

Pydantic model validating this provider's profile table.

display_name str

Human-readable provider name shown in the dashboard.

install_url str

Where users install or learn about the native CLI.

api_version int

Plugin API version the implementation was written against.

assigns_session_id bool

When True, Curupira generates a UUID for each new run, reports it before the process starts, and passes it to build_arguments as request.new_session_id. Use it for CLIs that accept a caller-chosen session identifier instead of printing their own.

executable class-attribute

provider class-attribute

profile_model class-attribute

display_name class-attribute

install_url class-attribute

api_version = PLUGIN_API_VERSION class-attribute

assigns_session_id = False class-attribute

__init__(runner=None)

build_arguments(request) abstractmethod

Translate a validated profile into native CLI arguments.

session_id_from_line(line)

Return the native session identifier announced by one stdout line, if any.

The default reads sessionID, session_id, or thread_id from a JSON event. Override it for CLIs that report their session in another shape. Each distinct identifier is reported once per run, however often it repeats.

Parameters:

Name Type Description Default
line str

One line of the CLI's standard output.

required

Returns:

Type Description
str | None

The session identifier, or None when the line does not announce one.

run_task(request, *, on_session_started=None) async

Run headlessly and report native session identifiers while streaming.

Adapters with assigns_session_id get a fresh UUID on new runs; it is reported through on_session_started before the process starts, so it is persisted even if the process dies early.

render_output(output)

Extract the final answer from provider-native JSONL output.

The default joins OpenCode text parts, Codex agent_message items, and Claude Code or Cursor result events. Adapters whose CLI emits a different final-answer shape override this method.

Parameters:

Name Type Description Default
output str

Captured standard output of the CLI.

required

Returns:

Type Description
str

Human-readable text shown to users and stored as the task result.

curupira.models.profiles.CliProfileBase pydantic-model

Bases: ValidatedModel

Options common to every supported CLI.

Agent plugins extend this model and give provider a default equal to their registered provider.

Attributes:

Name Type Description
provider NonEmptyString

Registered coding-agent provider selecting the profile model.

model NonEmptyString | None

Optional provider-specific model identifier.

Show JSON schema:
{
  "additionalProperties": false,
  "description": "Options common to every supported CLI.\n\nAgent plugins extend this model and give ``provider`` a default equal to their\nregistered provider.\n\nAttributes:\n    provider: Registered coding-agent provider selecting the profile model.\n    model: Optional provider-specific model identifier.",
  "properties": {
    "provider": {
      "minLength": 1,
      "title": "Provider",
      "type": "string"
    },
    "model": {
      "anyOf": [
        {
          "minLength": 1,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Model"
    }
  },
  "required": [
    "provider"
  ],
  "title": "CliProfileBase",
  "type": "object"
}

Fields:

provider pydantic-field

model = None pydantic-field

model_config = ConfigDict(extra='forbid', frozen=True, validate_default=True) class-attribute instance-attribute