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:
- A configuration model that extends
AutomationConfigurationBaseand givestrigger_typea default equal to the plugin's trigger type. It inheritsrepo,path,setup_script,checkout,prompt, andprofile, and adds the plugin's own options as Pydantic fields and validators. - A
TaskSourcethat turns one poll intoTaskobjects. Wrap it inPollingTaskFeedto get deduplication and backoff for free. - A
Triggerthat 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:
- A profile model that extends
CliProfileBaseand givesprovidera default equal to the plugin's provider. It inheritsmodeland adds the CLI's own options as Pydantic fields and validators. - A
CodingAgentCliAdaptersubclass that declares the provider metadata and translates aCodingTaskRequestinto 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 |
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:
-
trigger_type(NonEmptyString) -
repo(NonEmptyString) -
path(Path | None) -
setup_script(str | None) -
checkout(Literal['worktree', 'main']) -
prompt(str) -
profile(Identifier | None)
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 |
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 |
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 |
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: