TalkOps Server Addon Microkernel — Built-In Addons & Operator Guide
TalkOps Server Mode (talkops serve) is built around a pluggable Server Addon Microkernel (src/talkops/addons/). Every HTTP route, webhook listener, authentication provider, background consumer, and ChatOps integration in talkops serve is a modular Server Addon.
By default, TalkOps ships and enables three built-in Open-Source (OSS) addons so the standard CLI, server, and webhook workflows work out of the box with zero configuration:
core-auth(CoreAuthAddon) — Static API Key, Bearer token, 1-step JWT exchange, and OIDC/JWKS discovery endpoints.client-gateway(ClientGatewayAddon) — Multi-user/api/v1/threads/*,/api/v1/server/info, and background task SSE streaming for remote TUI/CLI clients.webhooks(WebhookAddon) — Inbound webhook ingestion (/webhooks/{provider}) for PagerDuty, AWS SNS, Alertmanager, Grafana, GitHub, Jenkins, Datadog, ServiceNow, Azure Monitor, and CloudEvents.
Beyond these built-in defaults, TalkOps-supported enterprise addons and user-authored custom addons can live in completely separate Git repositories and be hooked into TalkOps at runtime without modifying a single line of core TalkOps code.
Table of Contents
- Architecture Overview
- Addon Conventions & Protocol Reference
- Step-by-Step: Creating an Addon in a Separate Repository
- Interacting with the Agent Runtime (
AgentRunGateway&HookEventBus) - Hooking Your Addon into TalkOps at Runtime (4 Channels)
- Secrets Management (
.talkops/.env) & 4-Tier Configuration - Testing Your Addon End-to-End
- Developer Checklist & Quick Reference
1. Architecture Overview
Key Architectural Principles
- Zero Disturbance to Default Flow: Running
talkops servewith no flags startscore-auth,client-gateway, andwebhooksidentically to legacy behavior. - Zero HTTP Loopback: Addons never need to make HTTP requests back to
127.0.0.1:2024. Every addon receives anAddonContextcontainingctx.gateway(AgentRunGateway) andctx.event_bus(HookEventBus), which execute directly against the in-memory compiled LangGraphPregelgraph. - No Hardcoded Secrets: Core TalkOps never hardcodes third-party secret vaults or enterprise credentials. Users supply secrets via
.talkops/.env,~/.talkops/.env, or environment variables, and reference them inaddons.jsonusing${ENV_VAR}or<field>_env.
2. Addon Conventions & Protocol Reference
2.1 Naming & Metadata Conventions
Every addon subclasses BaseServerAddon (or implements the ServerAddon protocol) and defines the following class attributes:
| Attribute | Type | Convention / Requirement | Example |
|---|---|---|---|
name | str | Required. Unique, lowercase kebab-case identifier. Env vars map <NAME> by uppercasing and replacing - with _. | "talkops-slack" |
version | str | Semantic version string (MAJOR.MINOR.PATCH). | "1.0.0" |
api_version | int | Must be 1 (ADDON_API_VERSION). Checked by AddonManager at load time. | 1 |
tier | Literal["oss", "enterprise", "custom"] | "oss" for built-in core, "enterprise" for TalkOps commercial packages, "custom" for user-authored plugins. | "enterprise" |
description | str | Concise human-readable summary shown in talkops serve addons and GET /api/v1/addons. | "Two-way Slack ChatOps & HITL approvals" |
enabled_by_default | bool | True only for built-in OSS addons or drop-in .talkops/addons/*.py. External repo addons should set False and be enabled via addons.json, profile, or --addons. | False |
dependencies | tuple[str, ...] | Tuple of addon names that must start before this addon. AddonManager topologically sorts addons using Kahn's algorithm. | ("core-auth",) |
conflicts_with | tuple[str, ...] | Tuple of addon names that cannot be active at the same time (or that an enterprise addon supersedes). | ("core-auth",) |
settings_schema | type[AddonSettings] | Pydantic v2 model subclassing AddonSettings used to validate and type-check the addon's configuration. | SlackAddonSettings |
capabilities | AddonCapabilities | Declares which extension surfaces the addon uses. | AddonCapabilities(routes=True, gateway_runner=True) |
2.2 AddonCapabilities Flags
Declare which extension hooks your addon implements via AddonCapabilities:
from talkops.addons import AddonCapabilities
capabilities = AddonCapabilities(
routes=True, # Exposes Starlette Route / Mount endpoints via get_routes()
middleware=False, # Wraps the ASGI app via get_middlewares()
background_workers=False, # Runs async polling/queue tasks via get_background_workers()
companion_processes=False, # Spawns OS sidecar processes via get_companion_processes()
auth_provider=False, # Provides an IdentityMapper via get_identity_mapper()
event_sinks=False, # Publishes outbound notifications on run completion
mcp_provider=False, # Registers or proxies MCP tool servers
gateway_runner=True, # Triggers or resumes agent runs via ctx.gateway
event_subscriber=True, # Subscribes to HookEventBus events (e.g. HITL interrupts)
)
2.3 Configuration Schema Convention (AddonSettings)
Every addon defines its typed configuration by subclassing AddonSettings. AddonSettings automatically provides common base fields (enabled, port, mount_prefix, package, path, python_path, config):
from pydantic import Field
from talkops.addons import AddonSettings
class TalkOpsSlackSettings(AddonSettings):
"""Typed settings for the TalkOps Slack Addon."""
bot_token: str = Field(default="", description="Slack Bot OAuth Token (xoxb-...)")
signing_secret: str = Field(default="", description="Slack HMAC Signing Secret")
default_channel: str = Field(default="#sre-alerts", description="Default channel for incident threads")
max_concurrent_threads: int = Field(default=10, ge=1, le=100)
When AddonManager resolves configuration, it validates the merged dictionary against settings_schema.model_validate(raw_config) and passes the validated instance to addon.configure(config). Inside your addon, self.config (or self._config) holds this typed TalkOpsSlackSettings object.
2.4 Addon Lifecycle Hooks (BaseServerAddon)
AddonManager orchestrates every enabled addon through a strict lifecycle:
addon.configure(config: AddonSettings) -> NoneCalled duringresolve_and_validate()before the ASGI app is constructed. Storeself._config = configand validate any required settings.addon.get_routes() -> list[BaseRoute]Return StarletteRouteorMountobjects.- If
config.mount_prefixis set (e.g."/custom/v1"),AddonManagerautomatically prefixes your routes. - If
config.portis set to a different port than the main server port (e.g.port: 8090),AddonManagerautomatically mounts your routes on a dedicateduvicorn.Serverbound to port8090sharing the sameasyncioevent loop!
- If
addon.get_middlewares() -> list[Middleware]Return StarletteMiddleware(MyMiddlewareClass, ...)entries to wrap the server's HTTP stack.await addon.on_startup(ctx: AddonContext) -> NoneCalled inside the Starlette lifespan startup phase. Saveself._ctx = ctx, subscribe toctx.event_bus, initialize HTTP clients or DB pools.addon.get_background_workers(ctx: AddonContext) -> list[Callable[[], Coroutine]]Return zero-argument async callables (async def _poll_queue() -> None: ...).AddonManagerlaunches each worker as a supervisedasyncio.Taskon startup and cancels/awaits it cleanly on shutdown.addon.get_companion_processes(ctx: AddonContext) -> list[CompanionProcessSpec]Return optional OS subprocess specifications (CompanionProcessSpec(name=..., command=[...], restart_policy="on-failure")) thatAddonManagerspawns and supervises alongside the main server.await addon.healthcheck() -> AddonHealthStatusCalled byGET /api/v1/addonsto report real-time addon health (status="ok" | "degraded" | "error",details={...}).await addon.on_shutdown() -> NoneCalled in reverse dependency order whentalkops serveshuts down. Close any open connections or clients here.
3. Step-by-Step: Creating an Addon in a Separate Repository
Suppose you are creating a separate repository talkops-enterprise-addons (or a customer is creating acme-talkops-addons) to house a Slack ChatOps + Human-in-the-Loop (HITL) Approval Addon.
Step 1: Repository Directory Structure
talkops-enterprise-addons/
├── pyproject.toml
└── src/
└── talkops_addons/
├── __init__.py
└── slack_chatops.py
Step 2: pyproject.toml (Declaring the talkops.addons Entry Point)
By declaring the [project.entry-points."talkops.addons"] group in pyproject.toml, any environment that installs this package (pip install / uv pip install) will automatically register the addon with TalkOps:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "talkops-enterprise-addons"
version = "1.0.0"
description = "TalkOps Enterprise Server Addons for TalkOps"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"talkops>=0.1.0",
"httpx>=0.27.0",
]
# Automatic discovery hook for TalkOps AddonManager
[project.entry-points."talkops.addons"]
talkops-slack = "talkops_addons.slack_chatops:TalkOpsSlackAddon"
Step 3: Implementing the Addon (src/talkops_addons/slack_chatops.py)
Below is a complete, production-grade implementation showing:
- Typed
AddonSettingsconfiguration - HMAC signature verification using secrets from
.talkops/.env - Triggering background agent runs via
ctx.gateway.submit_run()with deterministic thread mapping (slack:{channel}:{thread_ts}) - Subscribing to
addon.run.interruptedonctx.event_busto capture Human-in-the-Loop tool approvals - Resuming paused agent runs via
ctx.gateway.resume_run()when an operator clicks Approve or Reject in Slack
"""TalkOps Slack ChatOps & HITL Approval Addon (External Repository Example)."""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
from pydantic import Field
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute, Route
from talkops.addons import (
AddonCapabilities,
AddonContext,
AddonEvent,
AddonHealthStatus,
AddonSettings,
BaseServerAddon,
)
class TalkOpsSlackSettings(AddonSettings):
"""Configuration schema for `talkops-slack`."""
bot_token: str = Field(default="", description="Slack Bot OAuth token (xoxb-...)")
signing_secret: str = Field(default="", description="Slack app signing secret")
default_channel: str = Field(default="#sre-war-room", description="Default Slack channel")
verify_signatures: bool = Field(default=True, description="Enforce X-Slack-Signature HMAC check")
class TalkOpsSlackAddon(BaseServerAddon):
"""Two-way Slack ChatOps & interactive Human-in-the-Loop approval addon."""
name = "talkops-slack"
version = "1.0.0"
tier = "enterprise"
description = "Slack ChatOps bot with interactive HITL tool approval cards"
enabled_by_default = False
dependencies = ("core-auth",)
settings_schema = TalkOpsSlackSettings
capabilities = AddonCapabilities(
routes=True,
gateway_runner=True,
event_subscriber=True,
)
def __init__(self) -> None:
super().__init__()
self._pending_approvals: dict[str, dict[str, Any]] = {}
self._unsubscribe_bus: list[Any] = []
async def on_startup(self, ctx: AddonContext) -> None:
await super().on_startup(ctx)
# Subscribe to HITL interrupt and run completion events from HookEventBus
unsub_int = ctx.event_bus.subscribe("addon.run.interrupted", self._on_run_interrupted)
unsub_done = ctx.event_bus.subscribe("addon.run.completed", self._on_run_completed)
self._unsubscribe_bus.extend([unsub_int, unsub_done])
async def on_shutdown(self) -> None:
for unsub in self._unsubscribe_bus:
unsub()
self._unsubscribe_bus.clear()
await super().on_shutdown()
async def _on_run_interrupted(self, event: AddonEvent) -> None:
"""Capture HITL interrupts so Slack can render Approve / Reject buttons."""
if not event.thread_id:
return
self._pending_approvals[event.thread_id] = {
"thread_id": event.thread_id,
"run_id": event.run_id,
"interrupts": event.payload.get("interrupts", []),
"status": "awaiting_approval",
}
async def _on_run_completed(self, event: AddonEvent) -> None:
"""Clear pending approval state once a thread finishes executing."""
if event.thread_id:
self._pending_approvals.pop(event.thread_id, None)
def _verify_slack_signature(self, headers: dict[str, str], raw_body: bytes) -> bool:
cfg: TalkOpsSlackSettings = self.config # type: ignore[assignment]
if not cfg.verify_signatures or not cfg.signing_secret:
return True
timestamp = headers.get("x-slack-request-timestamp", "")
signature = headers.get("x-slack-signature", "")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
sig_basestring = f"v0:{timestamp}:{raw_body.decode('utf-8')}".encode("utf-8")
expected = "v0=" + hmac.new(
cfg.signing_secret.encode("utf-8"),
sig_basestring,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
def get_routes(self) -> list[BaseRoute]:
return [
Route("/addons/slack/events", self._handle_slack_events, methods=["POST"]),
Route("/addons/slack/interactions", self._handle_slack_interactions, methods=["POST"]),
Route("/addons/slack/status", self._handle_status, methods=["GET"]),
]
async def _handle_slack_events(self, request: Request) -> JSONResponse:
raw_body = await request.body()
headers = {k.lower(): v for k, v in request.headers.items()}
if not self._verify_slack_signature(headers, raw_body):
return JSONResponse({"error": "Invalid Slack signature"}, status_code=401)
payload = await request.json()
# 1. Slack URL Verification Challenge
if payload.get("type") == "url_verification":
return JSONResponse({"challenge": payload.get("challenge", "")})
# 2. App Mention -> Trigger or Continue an TalkOps Investigation Thread
event = payload.get("event") or {}
if event.get("type") == "app_mention":
channel = event.get("channel", "C000")
thread_ts = event.get("thread_ts") or event.get("ts") or "0"
text = str(event.get("text") or "").strip()
# Deterministic source_key maps the same Slack thread to the same LangGraph UUID thread!
source_key = f"slack:{channel}:{thread_ts}"
handle = await self.context.gateway.submit_run(
prompt=text,
addon_name=self.name,
source_key=source_key,
metadata={"slack_channel": channel, "slack_thread_ts": thread_ts},
wait=False,
)
return JSONResponse(
{
"status": "accepted",
"run_id": handle.run_id,
"thread_id": handle.thread_id,
},
status_code=202,
)
return JSONResponse({"status": "ignored"}, status_code=200)
async def _handle_slack_interactions(self, request: Request) -> JSONResponse:
"""Handle interactive button clicks ('approve' / 'reject') from Slack Block Kit cards."""
payload = await request.json()
actions = payload.get("actions") or []
if not actions:
return JSONResponse({"error": "No actions provided"}, status_code=400)
action = actions[0]
decision = str(action.get("action_id") or "approve").lower()
thread_id = str(action.get("value") or "")
if not thread_id:
return JSONResponse({"error": "Missing thread_id in action value"}, status_code=400)
handle = await self.context.gateway.resume_run(
thread_id=thread_id,
decision=decision,
addon_name=self.name,
wait=False,
)
return JSONResponse(
{
"status": "resumed",
"decision": decision,
"run_id": handle.run_id,
"thread_id": handle.thread_id,
},
status_code=200,
)
async def _handle_status(self, _request: Request) -> JSONResponse:
cfg: TalkOpsSlackSettings = self.config # type: ignore[assignment]
return JSONResponse(
{
"addon": self.name,
"default_channel": cfg.default_channel,
"bot_token_configured": bool(cfg.bot_token),
"pending_approvals": list(self._pending_approvals.values()),
}
)
async def healthcheck(self) -> AddonHealthStatus:
cfg: TalkOpsSlackSettings = self.config # type: ignore[assignment]
return AddonHealthStatus(
name=self.name,
status="ok" if cfg.bot_token else "degraded",
tier=self.tier,
version=self.version,
details={
"bot_token_configured": bool(cfg.bot_token),
"pending_approvals": len(self._pending_approvals),
},
)
4. Interacting with the Agent Runtime (AgentRunGateway & HookEventBus)
Every addon receives ctx: AddonContext in on_startup(ctx). Its two most important properties are ctx.gateway (AgentRunGateway) and ctx.event_bus (HookEventBus).
4.1 AgentRunGateway Reference
| Method | Signature | Description |
|---|---|---|
resolve_thread_id | resolve_thread_id(thread_id=None, *, source_key=None) -> str | Converts an arbitrary external correlation key (e.g. "pagerduty:INC-123" or "slack:C123:1700.01") into a deterministic UUIDv5 thread_id. |
submit_run | await submit_run(prompt, *, thread_id=None, source_key=None, addon_name="custom", context=None, metadata=None, wait=False) -> RunHandle | Starts an in-memory agent run in the background (wait=False) or foreground (wait=True). Returns a RunHandle with run_id, thread_id, status, analysis_text, and interrupts. |
trigger_run | await trigger_run(prompt_or_input, *, thread_id=None, source_key=None, source_addon="webhooks", wait=True) -> RunHandle | Same as submit_run, but defaults to wait=True (awaits completion before returning). |
stream_run | stream_run(input_payload, *, thread_id, run_id=None, source_addon="...", stream_mode=None, subgraphs=True) | Async generator yielding (namespace, mode, chunk) tuples directly from graph.astream() for real-time SSE streaming. |
resume_run | await resume_run(thread_id, resume_payload=None, *, decision=None, addon_name="chatops", wait=True) -> RunHandle | Resumes a paused HITL interrupt using langgraph.types.Command(resume=...). Pass decision="approve" or decision="reject", or a full {"decisions": [...]} dict. |
get_thread_state | await get_thread_state(thread_id) -> Any | Reads checkpoint state directly via graph.aget_state(). |
update_thread_state | await update_thread_state(thread_id, values, *, as_node=None) -> Any | Updates checkpoint state directly via graph.aupdate_state(). |
cancel_run | await cancel_run(thread_id, run_id) -> bool | Cancels an in-flight asyncio.Task for the specified run. |
4.2 HookEventBus Standard Events
Subscribe to any event (or "*" for all events) via ctx.event_bus.subscribe(event_type, callback):
| Event Type | When Published | event.payload Fields |
|---|---|---|
addon.run.started | When stream_run / submit_run begins executing a turn | thread_id, run_id, source_addon |
addon.run.interrupted | When a tool call hits a Human-in-the-Loop approval gate (__interrupt__) | thread_id, run_id, interrupts (list of action requests) |
addon.run.completed | When a run finishes normally | thread_id, run_id, status, analysis_text |
addon.run.failed | When a run raises an unhandled exception | thread_id, run_id, error |
addon.run.cancelled | When a run is cancelled via cancel_run() | thread_id, run_id |
5. Hooking Your Addon into TalkOps at Runtime (4 Channels)
You never need to fork or edit the core talkops repository to load addons from another repository. Choose whichever runtime channel fits your deployment workflow:
Channel 1: Declarative addons.json Manifest (Recommended for Separate Repos & Docker/K8s)
Just like .talkops/mcp.json configures MCP servers, TalkOps automatically looks for addons.json in:
~/.talkops/addons.json(User global)<project_root>/.talkops/addons.jsonor<project_root>/addons.json(Project workspace)--addons-config /path/to/addons.jsonorTALKOPS_ADDONS_CONFIG=/path/to/addons.json(Explicit CLI/Env override)
{
"profile": "default",
"addons": {
"talkops-slack": {
"enabled": true,
"package": "talkops_addons.slack_chatops:TalkOpsSlackAddon",
"python_path": [
"/opt/talkops-enterprise-addons/src"
],
"config": {
"bot_token": "${TALKOPS_SLACK_BOT_TOKEN}",
"signing_secret_env": "TALKOPS_SLACK_SIGNING_SECRET",
"default_channel": "#sre-war-room",
"verify_signatures": false
}
},
"servicenow-bridge": {
"enabled": true,
"path": "/opt/custom-addons-repo/servicenow_addon.py:ServiceNowAddon",
"port": 8091,
"mount_prefix": "/snow",
"config": {
"instance_url": "https://acme.service-now.com",
"api_key": "${SERVICENOW_API_KEY}"
}
}
}
}
package+python_path: Imports a dotted Python module (pkg.mod:ClassName) and optionally prepends one or more external repository directories tosys.path.path: Loads a.pyfile or package directory directly from another repository on disk (/abs/path/to/file.py:ClassName).port: Optional dedicated port. When set (e.g.,"port": 8091),AddonManagerspins up a dedicateduvicorn.Serverlistener on port8091for this addon's routes!mount_prefix: Optional URL prefix prepended to all routes returned byget_routes().
Launch the server with:
talkops serve --addons-config /path/to/addons.json
Channel 2: Python Packaging entry_points (Zero-Config pip / uv Install)
If the external repository is installed into the Python environment:
uv pip install git+https://github.com/talkops/talkops-enterprise-addons.git
# or for local editable development across two repos:
uv pip install -e ../talkops-enterprise-addons
Because pyproject.toml declares [project.entry-points."talkops.addons"], TalkOps discovers talkops-slack automatically! You can then enable it via CLI flag, environment variable, or addons.json:
talkops serve --addons talkops-slack
# or
TALKOPS_ADDONS=talkops-slack talkops serve
Channel 3: Drop-In .talkops/addons/*.py or --custom-addon CLI Flag
For rapid prototyping or single-file custom integrations:
- Drop any
.pyfile containing aBaseServerAddonsubclass into<project_root>/.talkops/addons/or~/.talkops/addons/. - Or pass
--custom-addonon the command line:
talkops serve \
--custom-addon /opt/talkops-enterprise-addons/src/talkops_addons/slack_chatops.py:TalkOpsSlackAddon \
--addons talkops-slack
Channel 4: Programmatic Registration (@register_addon)
When embedding TalkOps in a custom Python entrypoint or test suite, decorate your class with @register_addon:
from talkops.addons import BaseServerAddon, register_addon
@register_addon
class MyEmbeddedAddon(BaseServerAddon):
name = "my-embedded-addon"
...
6. Secrets Management (.talkops/.env) & 4-Tier Configuration
6.1 Providing Secrets via .talkops/.env or Environment Variables
TalkOps never hardcodes secret values or cloud secret fetchers inside the addon config package. Instead, operators provide secrets in:
<project_root>/.talkops/.envor<project_root>/.env~/.talkops/.env- Standard OS / container environment variables (
export ...or KubernetesSecretenv injection)
Example .talkops/.env:
# TalkOps Slack Addon Secrets
TALKOPS_SLACK_BOT_TOKEN=xoxb-1234567890-abcdef
TALKOPS_SLACK_SIGNING_SECRET=9876543210fedcba
# Custom ServiceNow Addon Secret
SERVICENOW_API_KEY=snow_live_secret_token
In addons.json or .talkops/settings.json, you can bind those secrets into any addon's settings in three ways:
${ENV_VAR}interpolation:"bot_token": "${TALKOPS_SLACK_BOT_TOKEN}"<field>_envpointer (automatically populates<field>from the named env var):"signing_secret_env": "TALKOPS_SLACK_SIGNING_SECRET"- Direct
TALKOPS_ADDON_<NAME>_<FIELD>environment variable (no JSON needed at all):export TALKOPS_ADDON_TALKOPS_SLACK_BOT_TOKEN="xoxb-1234567890-abcdef"
export TALKOPS_ADDON_TALKOPS_SLACK_SIGNING_SECRET="9876543210fedcba"
export TALKOPS_ADDON_TALKOPS_SLACK_DEFAULT_CHANNEL="#sre-war-room"
6.2 4-Tier Configuration Precedence Hierarchy
When AddonManager resolves each addon's settings, higher tiers override lower tiers:
- Tier 1 (Lowest) —
AddonSettingsPydantic Defaults: Default field values declared on the addon'ssettings_schema. - Tier 2 — Server Profile (
--profile/TALKOPS_SERVER_PROFILE):default: Enablescore-auth,client-gateway,webhooks.api-only: Enablescore-auth,client-gateway(disableswebhooks).webhook-worker: Enableswebhooksonly.- Custom profiles can also be defined directly inside
addons.jsonunder"profiles":{
"profiles": {
"talkops-prod": ["core-auth", "client-gateway", "webhooks", "talkops-slack"]
},
"profile": "talkops-prod"
}
- Tier 3 —
addons.json/settings.json/--addons-config: Merged from~/.talkops/addons.json->.talkops/addons.json->--addons-config. - Tier 4 (Highest) — Environment Variables & CLI Flags:
TALKOPS_ADDON_<NAME>_<FIELD>=<value>--addons a,b(TALKOPS_ADDONS=a,b)--disable-addons c(TALKOPS_DISABLE_ADDONS=c)
7. Testing Your Addon End-to-End
7.1 CLI Inspection Commands
Before starting the server, verify that TalkOps discovers your external addon and resolves its status and tier:
# Pretty table output
talkops serve addons --addons-config ./addons.json
# Machine-readable JSON output
talkops serve addons --addons-config ./addons.json --json
Once the server is running, query the live /api/v1/addons discovery and healthcheck endpoint:
curl -s http://127.0.0.1:2024/api/v1/addons | jq .
7.2 Writing Automated pytest Unit Tests for External Addons
You can test your external addon in-memory (including AgentRunGateway runs and HookEventBus HITL interrupts) in milliseconds using httpx.ASGITransport and create_webhooks_app:
import httpx
import pytest
from talkops.addons import AgentRunGateway, HookEventBus
from talkops.server import create_webhooks_app
from talkops_addons.slack_chatops import TalkOpsSlackAddon
@pytest.mark.asyncio
async def test_talkops_slack_addon_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TALKOPS_ADDON_TALKOPS_SLACK_BOT_TOKEN", "xoxb-test-token")
monkeypatch.setenv("TALKOPS_ADDON_TALKOPS_SLACK_VERIFY_SIGNATURES", "false")
bus = HookEventBus()
gateway = AgentRunGateway(event_bus=bus)
app = create_webhooks_app(
extra_addons=[TalkOpsSlackAddon()],
addons=["talkops-slack"],
gateway=gateway,
)
mgr = app.state.addon_manager
await mgr.startup()
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
) as client:
# 1. Verify /api/v1/addons reports talkops-slack as healthy
addons_resp = await client.get("/api/v1/addons")
assert addons_resp.status_code == 200
assert "talkops-slack" in addons_resp.json()["active_addons"]
# 2. Verify Slack URL verification challenge
chal_resp = await client.post(
"/addons/slack/events",
json={"type": "url_verification", "challenge": "challenge-ok"},
)
assert chal_resp.status_code == 200
assert chal_resp.json() == {"challenge": "challenge-ok"}
# 3. Verify addon status endpoint reflects secret from env
status_resp = await client.get("/addons/slack/status")
assert status_resp.status_code == 200
assert status_resp.json()["bot_token_configured"] is True
finally:
await mgr.shutdown()
8. Developer Checklist & Quick Reference
When building a new TalkOps Server Addon in an external repository, verify these items:
- Subclass
BaseServerAddonfromtalkops.addonsand set a unique lowercasekebab-casename(e.g."talkops-slack"),tier("enterprise"or"custom"), anddescription. - Define a Pydantic
AddonSettingssubclass assettings_schemafor all configurable fields and secrets. - Do not hardcode secrets: Document the environment variable names users should place in
.talkops/.envand reference via${ENV_VAR}or<field>_envinaddons.json. - Use
self.context.gateway(AgentRunGateway) to trigger/stream/resume agent runs in memory rather than making HTTP requests tolocalhost. - Use
self.context.gateway.resolve_thread_id(source_key=...)so repeated events from the same external incident/thread map to the same LangGraph conversation thread. - Implement
healthcheck()returningAddonHealthStatusso operators can monitor the addon viaGET /api/v1/addons. - Register the entry point under
[project.entry-points."talkops.addons"]inpyproject.tomland/or provide a sampleaddons.jsonsnippet for--addons-config.