Skip to content

Latest commit

 

History

History
3060 lines (2496 loc) · 126 KB

File metadata and controls

3060 lines (2496 loc) · 126 KB

Plugin Authoring Guide

Status: Phase 1 (Issue #89). Audience: third-party developers shipping a pip-installable plugin that adds a new ad-platform provider (and optionally skills) to mureo.

mureo's provider abstraction lets any pip-installable package extend mureo with new ad platforms (e.g. Microsoft/Bing Ads, Apple Search Ads, TikTok Ads, LinkedIn Ads, X Ads, in-house platforms) without touching mureo's source tree. This guide walks through the plugin contract, shows a minimal working example, and documents the distribution patterns we support.

For the ABI stability contract that governs which changes are breaking and which are not, see ABI-stability.md.

Table of contents

  1. Introduction
  2. Quick start: a minimal plugin
  3. Provider Protocols
  4. Capabilities
  5. Models: frozen dataclasses and enums
  6. Skill matching
  7. Distribution patterns
  8. Entry-points registration
  9. Shipping skills with your plugin
  10. Security considerations
  11. End-to-end example
  12. Troubleshooting
  13. Web extensions
  14. Shipping analytics with your plugin
  15. Multi-tenant backend authoring

1. Introduction

Architecture in one paragraph

mureo's provider layer is split into three concentric ABIs:

  1. Capabilities — a StrEnum of 13 stable identifiers (read_campaigns, write_budget, ...). Capabilities are the currency that skills declare they need and that providers declare they offer.
  2. ProtocolsBaseProvider plus four domain Protocols (CampaignProvider, KeywordProvider, AudienceProvider, ExtensionProvider). Each domain Protocol fixes a small set of synchronous method signatures using a shared vocabulary of frozen dataclasses.
  3. Registry — entry-points-based discovery (mureo.providers group). Plugins register a class object; mureo defers instantiation, isolates per-plugin faults, and applies a first-wins policy on duplicate names.

A plugin is, in the smallest case, a Python package that:

  • declares one provider class with three class attributes (name, display_name, capabilities),
  • implements at least one domain Protocol,
  • registers the class under the mureo.providers entry-point group in its pyproject.toml.

That is enough to be discovered by mureo discover_providers() and matched against the 16 built-in skills.

What a plugin can do (and cannot do, in Phase 1)

Phase 1 in scope:

  • Add a new ad-platform provider implementing any subset of the four domain Protocols.
  • Ship a directory of SKILL.md files via the mureo.skills entry-points group; skills are discovered, validated, and matched against provider capabilities.
  • Plug into the deterministic skill ↔ provider matcher (three-bucket classification: executable / advisory_only / unavailable).

Phase 1 explicitly out of scope (will land in later phases — your plugin should not depend on them):

  • A standard authentication contract. Phase 1 keeps BaseProvider authentication-free; each adapter decides its own credential loading. A future AuthenticatedProvider Protocol will layer on top without breaking existing plugins.
  • Runtime authorization checks (permit()). Today the matcher gates purely against the declared capabilities frozenset.
  • MCP tool auto-generation from the Protocol. A provider is not published as MCP tools merely by implementing a domain Protocol. Exposure is opt-in: implement the secondary MCPToolProvider Protocol (see Section 3, "Exposing operations as MCP tools") and the MCP server discovers and publishes your tools. A provider that does not implement it is still discovered and skill-matched — just not exposed as MCP tools.
  • Thread safety. Discovery and registration run single-threaded at CLI / MCP-server startup.

2. Quick start: a minimal plugin

The smallest functioning plugin is a single-file provider that implements BaseProvider plus one domain Protocol, plus a pyproject.toml entry. Below is a complete working example for a fictional platform acme_ads.

pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mureo-acme-ads"
version = "0.1.0"
description = "ACME Ads provider for mureo"
requires-python = ">=3.10"
dependencies = [
    "mureo>=0.8,<1",
]

[project.entry-points."mureo.providers"]
acme_ads = "mureo_acme_ads.adapter:AcmeAdsAdapter"

[tool.hatch.build.targets.wheel]
packages = ["mureo_acme_ads"]

The entry-point key (acme_ads) must match the provider class's name attribute. mureo enforces snake_case on provider names (^[a-z][a-z0-9_]*$).

mureo_acme_ads/__init__.py

"""ACME Ads provider for mureo."""

from mureo_acme_ads.adapter import AcmeAdsAdapter

__all__ = ["AcmeAdsAdapter"]

mureo_acme_ads/adapter.py

"""ACME Ads adapter — minimal CampaignProvider implementation."""

from __future__ import annotations

from datetime import date

from mureo.core.providers import (
    Ad,
    AdStatus,
    Campaign,
    CampaignFilters,
    Capability,
    CreateAdRequest,
    CreateCampaignRequest,
    DailyReportRow,
    UpdateAdRequest,
    UpdateCampaignRequest,
)


class AcmeAdsAdapter:
    """ACME Ads provider implementing CampaignProvider (read-only)."""

    # BaseProvider class attributes — see Section 3.
    name: str = "acme_ads"
    display_name: str = "ACME Ads"
    capabilities: frozenset[Capability] = frozenset(
        {
            Capability.READ_CAMPAIGNS,
            Capability.READ_PERFORMANCE,
        }
    )

    def __init__(self, api_key: str) -> None:
        # Real plugins load credentials from env / a credentials file.
        # See Section 10 for the secret-management contract.
        self._api_key = api_key

    # CampaignProvider methods (signatures must match exactly).
    def list_campaigns(
        self, filters: CampaignFilters | None = None
    ) -> tuple[Campaign, ...]:
        # Talk to your platform here; return a tuple of Campaign.
        return ()

    def get_campaign(self, campaign_id: str) -> Campaign:
        raise NotImplementedError

    def create_campaign(self, request: CreateCampaignRequest) -> Campaign:
        raise NotImplementedError

    def update_campaign(
        self, campaign_id: str, request: UpdateCampaignRequest
    ) -> Campaign:
        raise NotImplementedError

    def list_ads(self, campaign_id: str) -> tuple[Ad, ...]:
        return ()

    def get_ad(self, campaign_id: str, ad_id: str) -> Ad:
        raise NotImplementedError

    def create_ad(self, campaign_id: str, request: CreateAdRequest) -> Ad:
        raise NotImplementedError

    def update_ad(
        self, campaign_id: str, ad_id: str, request: UpdateAdRequest
    ) -> Ad:
        raise NotImplementedError

    def set_ad_status(
        self, campaign_id: str, ad_id: str, status: AdStatus
    ) -> Ad:
        raise NotImplementedError

    def daily_report(
        self, campaign_id: str, start_date: date, end_date: date
    ) -> tuple[DailyReportRow, ...]:
        return ()

Verify discovery

After pip install -e .:

from mureo.core.providers import discover_providers, get_provider

discover_providers()
entry = get_provider("acme_ads")
print(entry.name, entry.display_name, sorted(str(c) for c in entry.capabilities))
print(entry.source_distribution)  # "mureo-acme-ads"

If the class fails validation (bad regex on name, wrong type on capabilities, etc.), discovery emits a RegistryWarning and skips the plugin — it does NOT raise. Use warnings.filterwarnings("error", category=RegistryWarning) during development to fail fast.


3. Provider Protocols

mureo uses PEP 544 structural typing: your class does not need to inherit from any base class. It just needs the right attributes and method signatures. All Protocols are @runtime_checkable, so isinstance(obj, CampaignProvider) returns True for any object exposing the required attributes.

BaseProvider (required by every plugin)

Defined in mureo.core.providers.base. Three class attributes:

Attribute Type Contract
name str Snake_case identifier matching ^[a-z][a-z0-9_]*$. Used as registry key and skill-frontmatter token.
display_name str Non-empty human-readable label (CLI output, error messages).
capabilities frozenset[Capability] The capabilities the provider declares it can serve. Must be a frozenset (not set) so it is hashable and cannot be mutated.

These should be class attributes (not instance attributes) so the registry and skill matcher can introspect them without instantiating the provider. validate_provider() and register_provider_class() both look them up on the class via getattr.

BaseProvider declares no methods. Phase 1 keeps it minimal so adding required methods later cannot break installed plugins — new behaviour goes into secondary Protocols.

Declaring per-account credential fields (optional)

Providers often need identifiers that vary per platform account — Google Ads customer_id, Meta Ads ad_account_id, an analytics product's advertiser_id. These are distinct from operator-shared OAuth credentials (developer tokens, refresh tokens) which typically apply to every account on the same platform.

Declare them via the optional account_credential_fields class attribute so introspection tooling — the mureo providers … CLI, configuration wizards, plugin authoring guides — can render setup prompts, validate config, and document your provider without hardcoding per-provider knowledge:

from mureo.core.providers import AccountCredentialField

class MyAdsProvider:
    name = "my_ads"
    display_name = "My Ads"
    capabilities = frozenset({...})
    account_credential_fields = (
        AccountCredentialField(
            key="advertiser_id",
            display_name="Advertiser ID",
            placeholder="adv-12345",
            required=True,
            description="Advertiser ID from the MyAds dashboard.",
        ),
    )
Field Default Notes
key (required) Stable snake_case identifier used in credential storage. Treat as part of your public ABI — renaming after release breaks operator config.
display_name (required) Human-readable label for CLI prompts / wizard forms. Used as the fallback when no display_name_i18n entry resolves for the active locale.
placeholder "" Example value shown in form inputs (never used as a default — auto-applying would surprise operators).
required False When True, tooling may warn or block on a missing value.
description "" One-line operator-facing hint pointing at where the value comes from. Used as the fallback when no description_i18n entry resolves.
secret False When True, consumers render a masked input and may pick tighter storage permissions. Use only when the per-account slice itself is sensitive (e.g. a per-account API key). See "Secret per-account fields" below.
display_name_i18n {} Optional {locale: label} map (BCP-47 codes). Resolved via i18n[locale] → i18n["en"] → display_name. Empty-string entries fall through to the next layer. Ship only the locales you have translated. See "Localised labels" below.
description_i18n {} Same shape and fallback chain as display_name_i18n, applied to the hint text.

Localised labels (*_i18n)

The configure UI carries an active locale per session (en / ja today; the lookup is BCP-47 so future locales drop in). Each plugin ships its own translation strings — mureo's OSS never ships non-English copy for plugin-declared fields. The resolution chain is i18n[locale] → i18n["en"] → display_name (and the equivalent for description); an empty-string entry is treated as "not declared" so a mistakenly-empty translation cannot blank the label.

account_credential_fields = (
    AccountCredentialField(
        key="business_id",
        display_name="Business ID",                          # universal fallback
        description="Yahoo! JAPAN Business ID.",
        required=True,
        display_name_i18n={"ja": "ビジネス ID"},               # per-locale label
        description_i18n={"ja": "Yahoo! JAPAN ビジネス ID。"},  # per-locale hint
    ),
)

A plugin that ships only English (no *_i18n declared) keeps working unchanged — both maps default to {} and every locale falls through to the bare display_name / description strings.

The accessor mureo.core.providers.get_account_credential_fields(provider) reads the attribute defensively (returns () when absent) and validates the shape — providers shipped before this feature existed keep loading without modification, but a malformed declaration (non-tuple, wrong element type) raises TypeError at introspection time so the failure surfaces near the plugin, not deep inside the consuming UI.

OAuth-level / operator-shared credentials (developer token, app secret, refresh token) intentionally do NOT live here — account_credential_fields is for the per-account slice only.

Multiple account-level fields

A provider can declare more than one AccountCredentialField when the per-account configuration genuinely requires multiple values. The built-in GoogleAdsAdapter is the canonical example: a single OAuth identity can reach accounts that live under different Manager (MCC) accounts, so both customer_id (which account to operate on) and login_customer_id (which manager account to route the call through) are per-account in that setup.

account_credential_fields = (
    AccountCredentialField(
        key="customer_id",
        display_name="Customer ID",
        placeholder="123-456-7890",
        required=True,
        description=(
            "10-digit Google Ads customer ID — find it in the "
            "Google Ads UI top-right corner."
        ),
    ),
    AccountCredentialField(
        key="login_customer_id",
        display_name="Login Customer ID (MCC)",
        placeholder="987-654-3210",
        required=False,
        description=(
            "Manager (MCC) Customer ID used as ``login_customer_id`` "
            "when calling the API. Leave blank to inherit the "
            "operator-wide MCC default; set per account when the "
            "target ``customer_id`` resolves through a different MCC."
        ),
    ),
)

Two patterns worth noting in the example:

  • Required + optional mixed: the primary identifier is required=True; an additional field can be required=False so tooling falls back to an operator-wide value when the per-account override is blank.
  • Cross-reference: the optional field's description points to where the value comes from — for Google Ads, the parent_id returned by mureo.google_ads.list_accessible_accounts for child accounts reached via MCC traversal. Surfacing this in the description lets tooling auto-populate the field from a discovery call.

Secret per-account fields

When a per-account field carries a secret value (API key, per-account OAuth token, etc.) rather than a public identifier, set secret=True. Consumers — configure wizards, third-party setup UIs — use the flag to:

  • Render the value as a masked / password-style input.
  • Avoid pre-populating the value on edit / re-display (a blank input conventionally means "keep the existing value").
  • Choose tighter storage permissions, typically 0o600, when the value lands in a file rather than an injected SecretStore.
account_credential_fields = (
    AccountCredentialField(
        key="api_key",
        display_name="API Key",
        placeholder="advertiser_api_key_xxxx",
        required=True,
        secret=True,
        description=(
            "Per-account API key sent in the X-API-Key header on "
            "every request. Each account has its own key."
        ),
    ),
)

The OSS-shipped GoogleAdsAdapter and MetaAdsAdapter do not use secret=True — their per-account fields are public identifiers (customer_id, ad_account_id, login_customer_id); the sensitive material (refresh tokens, system user tokens) is operator-shared and lives in the SecretStore base layer, not in account_credential_fields. Use secret=True when the per-account slice itself is the secret.

Declaring your platform's delivery model (optional)

Everything else a plugin contributes is read on demand. A SKILL.md you ship through mureo.skills is description-matched: the agent sees its frontmatter description and reads the body only if it decides the skill applies. That never happens on a daily-check or a weekly-report run — so a skill is the wrong place for "this is how my platform actually works", which is exactly the sentence that has to be in play while a report is being written (#648).

Register a PlatformModel instead. It is rendered into the MCP server's instructions, which the client receives inside the initialize response — before any tool call, and with no description to match:

from mureo.policy.learning_rules import Evidence
from mureo.policy.platform_model import PlatformModel, register_platform_model

register_platform_model(
    PlatformModel(
        platform="acme_ads",
        tool_prefix="acme_ads_",
        statement=(
            "Acme is a closed network: delivery is selected by eCPM "
            "(estimated CTR x CPC), not by an auction against other "
            "bidders. There is no win rate, no bid floor and no automated "
            "bid strategy — a 'bid' on Acme is the fixed CPC you set."
        ),
        evidence=Evidence(
            source="https://developers.acme.example/ads/delivery",
            retrieved="2026-08-19",
            quote="Ads are ranked by eCPM. Acme does not run an auction.",
        ),
    )
)

Call it at module import time — from your provider module, the one your mureo.providers entry point loads. No new entry-point group is involved: discovery imports your module while the MCP server is being built, which is before the server composes its instructions.

Field Contract
platform Your provider namemust match it, since it is both the label on the rendered line and the ownership key checked against the tools this server serves.
tool_prefix How mureo decides your platform is in scope. The statement is rendered only when this server serves at least one tool whose name starts with it and which your provider contributed — same attribution rule as PlatformLearningRules, plus an ownership check.
statement One paragraph of plain prose, at most MAX_STATEMENT_CHARS (400) characters, no line breaks.
evidence The same Evidence record the learning rules use: first-party source, ISO retrieved date, and the quote the statement rests on.

Four rules the registry enforces, and why:

  • Evidence is required. A model missing source, retrieved or quote — or carrying a retrieved that is not an ISO date — is refused with ValueError. The failure mode this exists to prevent is a plausible sentence; a plausible sentence with no source is indistinguishable from a correct one until it costs money.
  • Length is capped. A statement over 400 characters, or containing a line break, is refused at registration rather than silently truncated, so you find out immediately. The rendered block — heading, statement lines, the newlines joining them and the truncation notice — never exceeds MAX_TOTAL_CHARS (2000) in total; past that, whole statements are dropped in platform order, a warning names them in the log, and the block itself carries a line telling the agent the list is incomplete. Always-on text is a budget shared with everything else the agent has to read.
  • You may only speak for yourself. Registering is not permission. A model is rendered only where a tool starting with its tool_prefix was contributed by the provider named in platform — matching the prefix is a claim, and the server's tool-ownership map is what settles it. mureo's own built-in tools have no plugin owner, so a model claiming google_ads_ renders nothing no matter what platform says.
  • First registration wins. A second registration for a platform key that is already taken is dropped with a PlatformModelWarning, never substituted — the same rule provider names follow, so a package installed after a legitimate one cannot take the slot. Operators can fail closed with warnings.filterwarnings("error", category=PlatformModelWarning).
  • Scope, not installation. A platform whose tools this server does not serve contributes nothing, so an operator is never charged always-on context for a platform they are not running.
  • Silence is the default. mureo core registers no models of its own, and a platform with no registered model contributes no text at all. Absence is reported as silence, never filled in with a guess — the same rule mureo.policy.learning_rules applies to a platform it has no first-party enumeration for.

This is a trust boundary, and the rules above are not a review. register_platform_model runs inside a third-party module import and puts text in front of the agent unconditionally — that is the power it exists to grant. What mureo enforces is whose name a statement can be published under (ownership, first-wins) and how much of it there can be (the caps). What mureo cannot enforce is whether the statement is true: Evidence proves a source was named, not that the prose follows from it. A PlatformModel therefore warrants the same human, PR-level review as any other text your plugin publishes — read the statement against the quoted source, and re-read it when the platform's own documentation changes.

What belongs in the statement: how delivery is selected, how it is priced, and — the half that stops another platform's model being borrowed — what your platform therefore does not have. What does not belong: workflow advice, tool usage, metric definitions, anything that changes per account. Those are skills.

Declaring that your campaigns carry a monthly budget (optional)

Most platforms carry only a daily budget, and mureo's CampaignSnapshot.daily_budget is shaped for them. Some carry a monthly figure natively, alongside the daily one — and where an operator has set it, that is the intended monthly spend for that campaign, not a ceiling and not a derivation.

Two things have to happen for mureo to read it (#656).

1. Write the figure with your campaign snapshots. It rides in CampaignSnapshot.monthly_budget, beside daily_budget, via mureo_state_upsert_campaign or mureo.context.state.upsert_campaign. Omit it entirely on a per-day platform; never send a daily budget multiplied out, which is an implied cap and not what the campaign is set to spend. Do not write a total anywhere: mureo computes the sum on read, because a cached total is stale the moment one campaign's budget changes.

2. Declare that your platform has the concept, at module import time, from the same module your mureo.providers entry point loads:

from mureo.context.platform_monthly_budget import (
    MonthlyBudgetSupport,
    register_monthly_budget_support,
)
from mureo.policy.learning_rules import Evidence

register_monthly_budget_support(
    MonthlyBudgetSupport(
        platform="acme_ads",
        evidence=Evidence(
            source="https://developers.acme.example/reference/campaigns",
            retrieved="2026-08-19",
            quote="A campaign body accepts monthly_budget alongside daily_budget.",
        ),
    )
)
Field Contract
platform The STATE.json platforms key you write your campaigns under — normally your provider name. A declaration under any other key never applies to anything.
evidence The same Evidence record the learning rules and platform models use: first-party source, ISO retrieved date, and the quote it rests on. Incomplete evidence is refused with ValueError.

The declaration is not ceremony. It answers a question no campaign row can: whether an absent monthly_budget is a gap or a field your platform simply does not have. Without it, "this campaign's figure was not synced" and "this platform has no such field" are the same absence, and mureo could not tell a complete campaign set from a short one.

First registration wins, as for provider names and platform models: a second declaration for a taken key is dropped with a MonthlyBudgetSupportWarning, and warnings.filterwarnings("error", category=MonthlyBudgetSupportWarning) turns that into a startup failure for operators who want to fail closed. mureo core declares no platform of its own.

What mureo then does with it, in mureo.context.monthly_budget.resolve_monthly_budget:

  • the operator's ## Custom: Monthly Budget still wins — that is the agreed figure, and a configured sum is not an agreement. The sum comes back under its own source (platform_configured_sum, with is_platform_configured set) so no surface can state it as a promise a client made;
  • an incomplete set is never summed. If a campaign mureo holds for your platform has no readable figure, if it holds none at all, or if its last collection failed (not_collected), no total is produced — an IncompletePlatform(platform, reason) record comes back in incomplete_platforms instead, and one such platform withholds the whole cross-platform total. Three of a client's five campaigns is a smaller number, not a smaller budget.

If you declare the concept but never write the figures, every account on your platform reports reason="no_monthly_budgets" and this rung stays off — permanently, since first-wins means no later registration can take the slot back. That is deliberate: a declaration mureo cannot act on subtracts an answer rather than inventing one. It is also the reason the reason codes exist, so an operator reading IncompletePlatform.detail is told to come to you rather than left wondering why a monthly figure never appears. Declare it in the same change that starts writing monthly_budget, not before.

Domain Protocols (implement at least one)

Protocol Purpose Methods
CampaignProvider Campaigns, ads, daily-grain reporting list_campaigns, get_campaign, create_campaign, update_campaign, list_ads, get_ad, create_ad, update_ad, set_ad_status, daily_report
KeywordProvider Keywords + search-term reports (search platforms only) list_keywords, add_keywords, set_keyword_status, search_terms
AudienceProvider Audience / segment management list_audiences, get_audience, create_audience, set_audience_status
ExtensionProvider Ad extensions (sitelinks, callouts, conversions) list_extensions, add_extension, set_extension_status

Each Protocol is independent. Implementing a Protocol does not auto-grant capabilities — the matcher only looks at your declared capabilities frozenset (see Section 4).

Delete-via-status convention

There are no delete_* methods. Deletion is folded into status updates with the REMOVED enum member:

adapter.set_ad_status(campaign_id, ad_id, AdStatus.REMOVED)
adapter.set_keyword_status(campaign_id, keyword_id, KeywordStatus.REMOVED)
adapter.set_audience_status(audience_id, AudienceStatus.REMOVED)
adapter.set_extension_status(campaign_id, extension_id, ExtensionStatus.REMOVED)

Your adapter is responsible for translating REMOVED into the platform-native delete call (e.g. the Meta Custom Audiences delete endpoint, or the Google Ads remove operation). This convention keeps the Capability surface minimal — no DELETE_* capabilities exist.

Sync vs async

All Phase 1 Protocols are synchronous. If your underlying client is async, run it on a fresh event loop inside each method — this is the same pattern used by the built-in GoogleAdsAdapter and MetaAdsAdapter:

import asyncio
from collections.abc import Awaitable
from typing import TypeVar

_T = TypeVar("_T")

class MyAdapter:
    @staticmethod
    def _run(coro: Awaitable[_T]) -> _T:
        # Raises RuntimeError if called from inside a running loop —
        # that is the documented Phase 1 contract.
        return asyncio.run(coro)

    def list_campaigns(self, filters=None):
        return self._run(self._client.async_list_campaigns(filters))

If a caller is already inside an event loop, asyncio.run raises RuntimeError. That is the documented Phase 1 behaviour and is allowed to propagate.

Exposing operations as MCP tools (MCPToolProvider)

Implementing a domain Protocol makes your provider discoverable and skill-matchable. It does not, on its own, publish your operations as mcp__mureo__* tools. MCP exposure is a separate, opt-in secondary Protocolmureo.mcp.tool_provider.MCPToolProvider — that you implement in addition to BaseProvider / a domain Protocol:

from mcp.types import TextContent, Tool

class MyAdapter:               # already a CampaignProvider, etc.
    # ... name / display_name / capabilities / Protocol methods ...

    def mcp_tools(self) -> tuple[Tool, ...]:
        # MUST be pure and credential-free: it is called at MCP-server
        # start, before any API key is necessarily present. Return
        # static Tool definitions only — no network, no secret access.
        return MY_TOOLS

    async def handle_mcp_tool(
        self, name: str, arguments: dict
    ) -> list[TextContent]:
        # Called only for tool names you returned from mcp_tools().
        ...

isinstance (structural, runtime_checkable) is how the server detects the surface, so you do not need to import MCPToolProvider — matching the two method shapes is sufficient. This keeps your plugin working against any mureo release whose server performs the wiring, without a hard import dependency on it.

Rules the server enforces (a non-conforming provider is skipped with a PluginToolWarning, never fatal):

  • No-arg constructible. The server does YourClass() at startup. Resolve credentials lazily on first tool call, not in __init__.

  • mcp_tools() is static / credential-free. It runs before any secret is guaranteed present.

  • handle_mcp_tool must be async. A sync handler is rejected at collection time (the dispatch path is not fault-isolated).

  • Namespace your tool names. Prefix every tool with your provider name (e.g. acme_ads_list_campaigns). Built-in tool names are reserved — a colliding plugin tool is dropped (built-ins win), and a name already taken by an earlier plugin is dropped (first wins). Your guardrail declarations go with the dropped tool: mureo keys the budget/bid registries and the readOnlyHint registry by tool name alone, so a dropped tool contributes no declaration and every mureo guardrail on that name follows the plugin that won it. The drop is reported as a PluginToolWarning and on the log, naming both distributions and both providers, and it never stops the server — but an operator running two bridges that share a generic name like update_campaign has one of them silently unavailable until they look. A prefix is the only reliable fix.

    Reserved means every built-in name, including the families an operator has switched off with a MUREO_DISABLE_* env var. A built-in name belongs to mureo whether or not this particular run serves it, so a disabled family is not an opening to claim its names — the tool would otherwise change owner the moment the operator dropped the flag.

  • Keep inputSchema honest — mureo enforces it server-side. Since #324 the dispatcher validates every call against your declared inputSchema before it reaches your handler and rejects a violation with ValueError. Declare the types your handler actually accepts: a tool that declares {"type": "integer"} but tolerates "1000" will now have that call rejected before you see it. Beyond schema shape, still translate malformed arguments into your own error type rather than letting a bare KeyError/ValueError escape.

Sync clients: run blocking work off the event loop with asyncio.to_thread(...) inside handle_mcp_tool so you do not block the MCP server.

This is an opt-in, hand-written surface (the plugin author writes the Tool schemas). Auto-generating tools from the domain Protocol is intentionally not done — it cannot express platform-specific operations that fall outside the shared Protocol, which hand-written mcp_tools() can. See docs/mcp-server.md for the server-side view.

Safety treatment of plugin tool calls (mureo applies this for you)

Every plugin tool call is, automatically and without you opting in:

  • Audited — appended (secret-masked) to ~/.mureo/plugin_audit.jsonl. ok records whether the dispatch raised. If your handler reports a platform-side refusal without raising, return it as mureo's canonical API error: ... text envelope: the record is then additionally marked platform_ok: false with the reason, and the call is not promoted into action_log — nothing changed, so nothing is logged as a change.
  • Throttled — a conservative shared token bucket gates the plugin dispatch path. mureo never crashes on, nor silently swallows, a plugin exception: it is recorded then re-raised unchanged.

You can refine this purely with standard MCP Tool metadata — no mureo-specific Protocol method:

  • annotations=ToolAnnotations(readOnlyHint=True) → the tool is a read; it stays in the jsonl audit only. readOnlyHint=False → it mutates. Either way the declaration is believed verbatim.
  • No readOnlyHint at all (no annotations, or annotations that omit it) → the tool NAME decides, via mureo's shared read vocabulary (list_ / get_ / analyze_ / diagnose_ / inspect_ / report_ / check_ / search_ / query_, anchored per hyphen-delimited namespace segment). A name that does not read as a read falls through to the conservative mutating default. Declare the hint if you care — the name fallback exists for bridged surfaces that cannot.
  • A mutating call is additionally promoted into STATE.json's action_log (platform="plugin:<your-dist>") only when a STATE.json already exists in the cwd — mureo never creates one just because a plugin ran. This makes plugin mutations visible to the agent / strategy review / rollback_plan_get like a built-in op.
  • Optional _meta={"mureo": {...}} (the canonical alias; the intuitive meta= spelling is also accepted):
    • "reversal": {"operation": "...", "params": {...}} — recorded verbatim into the entry's reversible_params. Since #324 this is executable, not audit-only: rollback_plan_get builds a real reversal when operation names a registered, non-destructive tool — a built-in from mureo's rollback allow-list, or one of your own plugin tools. (An operation naming an unregistered or destructive tool is still recorded for audit but is not applied.) The reversal's params are bounded by the target tool's declared inputSchema property names.

    • Runtime-correct reversal (MCPReversibleToolProvider, #327/#328) — a static reversal cannot know the entity id the platform will mint, nor the prior state you are about to overwrite. Implement the optional capture_reversal method on your provider and mureo calls it before the mutation, recording what you return instead of the static hint:

      class AcmeAdsProvider:
          async def capture_reversal(
              self, name: str, arguments: dict[str, Any]
          ) -> dict[str, Any] | None:
              """Return the reversal for the call about to run, or None."""
              if name != "acme_ads_set_campaign_status":
                  return None
              prior = await self._client.get_campaign(arguments["campaign_id"])
              return {
                  "operation": "acme_ads_set_campaign_status",
                  "params": {
                      "campaign_id": arguments["campaign_id"],
                      "status": prior["status"],  # the state we are replacing
                  },
              }

      The method is async. Return None to fall back to the static hint. It is best-effort: an exception is logged and the mutation proceeds with the static hint (auditing must never break the call). For rollback_apply to execute it, operation must name a registered, non-destructive tool of the same plugin. A provider that does not implement it keeps its static meta["mureo"]["reversal"] behavior unchanged.

    • "throttle": {"rate": <float>, "burst": <int>, "hourly_limit": <int|null>} — a dedicated bucket for that tool; malformed/absent ⇒ shared default.

    • "observation_days": <positive int> — the outcome-review window for a mutating call. Absent/malformed ⇒ a conservative 14-day default. The promoted action_log entry's observation_due is set from this so daily-check's evidence step reviews the outcome like a built-in write (no metrics_at_action baseline ⇒ reviewed qualitatively).

    • "identity": {"campaign_id": "<argument key>", "ad_id": "<argument key>", "entity_type": "<literal kind>", "entity_id": "<argument key>"} — declares which mutation arguments identify the target recorded in action_log. campaign_id and ad_id are independently optional. entity_type and entity_id must be declared together; use the generic pair for ad groups, ad sets, placements, or another platform-specific sub-campaign entity. Common argument names (campaign_id, campaignId, ad_id, adId, and standard ad-group/ad-set/placement spellings) are detected without a declaration. An undeclared ad_id is treated as the canonical target, so a parent ad_group_id / ad_set_id required only as API context does not broaden the observation guard. An explicit generic declaration wins when the actual target is something else. Declare identity when your provider uses different names so daily-check can suppress repeated changes to the same target on the same platform while its observation window is open.

    • "budget": {"daily": "<key>", "lifetime": "<key>", "current": "<key>", "unit": "currency"|"micros"}declare where your tool carries its budget so STRATEGY.md ## Guardrails caps are enforced on your platform too (#414). See the next section.

    • "bid": {"bid_amount": "<key>", "cpc_bid": "<key>", "unit": "currency"|"micros"} — the bid twin of budget: declare where your tool carries its proposed bid so the max_bid_amount_per_ad_set / max_cpc_bid_per_ad_group caps are enforced on your platform too. See the bid-declarations section below.

Budget declarations — getting your platform under the Guardrails

mureo's built-in StrategyPolicyGate blocks any mutation that violates the operator's ## Guardrails (max_daily_budget_per_campaign, max_daily_budget_increase_pct, max_lifetime_budget_per_campaign, blocked_operations) before dispatch. To find the proposed budget it scans the argument keys the built-in Google/Meta tools use.

If your tool spells its budget any other way, the gate finds nothing and treats the call as "no budget proposed" — it is allowed through with no error and no warning, and the operator who wrote a cap believes it is protecting your platform when it is not. Declare your keys and that silent gap closes:

Tool(
    name="acme_ads_update_budget",
    description="Update a campaign's daily budget.",
    inputSchema={
        "type": "object",
        "properties": {
            "campaign_id": {"type": "string"},
            "daily_budget_micros": {"type": "integer"},
            "current_budget_micros": {"type": "integer"},
        },
        "required": ["campaign_id", "daily_budget_micros"],
    },
    _meta={
        "mureo": {
            "budget": {
                "daily": "daily_budget_micros",
                "current": "current_budget_micros",
                "unit": "micros",
            }
        }
    },
)
  • daily / lifetime — the argument key carrying the proposed daily / lifetime (period-total) budget. At least one is required.
  • current — optional, and usually unnecessary: the key carrying the existing daily budget. You only need it if your tool itself takes the current budget as an argument under some other name. Leave it out and mureo keeps reading its own current_daily_budget convention (in currency units), which is what the skills pass on every budget mutation — so max_daily_budget_increase_pct keeps working.
  • unit"currency" (default) or "micros" (the value is divided by 1,000,000). Stringified numbers ("20000") are accepted. Note this describes your declared keys only. The convention keys mureo reads for itself (current_daily_budget, projected_total_daily_budget) are always currency units, so a micros tool does not restate them — and should not declare current: "current_daily_budget" alongside unit: "micros", which would divide that baseline by 1e6 and report a ¥10,000 → ¥15,000 raise as a 149,999,900% one.

Semantics worth knowing before you declare:

  • A declaration replaces the built-in key scan for the budgets your tool proposes (daily, lifetime) — for every one of them, not only the ones you name. Your vocabulary is authoritative, so an unrelated field spelled amount cannot false-trip a cap. The corollary: if your tool also carries a lifetime budget, declare lifetime — declaring only daily opts the tool out of the built-in lifetime_budget / total_amount scan as well.
  • What the caller supplies is the exception. The existing daily budget (current_daily_budget) and the account-wide projected total (projected_total_daily_budget) are not budgets your tool proposes — they are context the skills compute and pass. A declaration does not replace them, so declaring daily never silently switches max_daily_budget_increase_pct or max_total_daily_budget off. The projected total has no declaration key at all, by design.
  • A declared key that is present but unreadable makes the gate deny. inf, nan, a bool, a non-numeric string, a nested object under your declared key ⇒ the cap cannot be verified, so the call is refused with a clear reason rather than waved through. (An absent key — or null / a blank string — simply means "no budget proposed on that channel" and is not a denial.)
  • A malformed declaration is rejected whole, never half-applied, and undeclared tools keep today's behavior byte-identical.

If a declaration fits your tools, you do not need to register your own mureo.policy_gates entry for budget enforcement — declare the keys and the one built-in gate does it. Some tool shapes it cannot fit; the next section is for those.

Bid declarations — getting your bids under the Guardrails

Bids have the same gap budgets did, and the same fix. The built-in gate also enforces two bid caps — max_bid_amount_per_ad_set (a per-auction ceiling in account-currency minor units, like Meta's bid_amount) and max_cpc_bid_per_ad_group (in account-currency units, like Google's cpc_bid_micros after ÷1e6). A bid is a per-auction ceiling, not a spend budget, so it gets its own caps. To find the proposed bid the gate scans the built-in Meta/Google keys; if your tool spells its bid any other way, the call is treated as "no bid proposed" and allowed through with no error and no warning. Declare your keys and that gap closes too:

Tool(
    name="acme_ads_update_bid",
    description="Update an ad group's max CPC bid.",
    inputSchema={
        "type": "object",
        "properties": {
            "ad_group_id": {"type": "string"},
            "bid_cap_micros": {"type": "integer"},
        },
        "required": ["ad_group_id", "bid_cap_micros"],
    },
    _meta={"mureo": {"bid": {"cpc_bid": "bid_cap_micros", "unit": "micros"}}},
)
  • bid_amount — the argument key carrying a bid capped by max_bid_amount_per_ad_set, compared in account-currency minor units (direct, like Meta's bid_amount).
  • cpc_bid — the argument key carrying a bid capped by max_cpc_bid_per_ad_group, compared in account-currency units (like Google's cpc_bid_micros after ÷1e6). At least one of bid_amount / cpc_bid is required.
  • unit"currency" (default) or "micros" (the value is divided by 1,000,000). The channel you name decides which cap constrains the bid; unit decides its units — set micros when your value is in micros so it lands in the cap's comparison unit, exactly as the built-in cpc_bid_micros path does. Stringified numbers ("20000") are accepted. One declaration carries one unit for both channels; a bid tool proposes a single bid, so the common case names exactly one channel.

The rest of the semantics match a budget declaration:

  • A declaration replaces the built-in bid key scan for that tool, so an unrelated field spelled bid_amount cannot false-trip a cap. (Unlike budgets there are no caller-supplied convention keys, so it replaces the whole bid scan.)
  • A declared key that is present but unreadable makes the gate deny. inf, nan, a bool, a non-numeric string, or a nested object under your declared key ⇒ the cap cannot be verified, so the call is refused rather than waved through, through the same fail-closed choke point the built-in scan uses. An absent key — or null / a blank string — simply means "no bid proposed".
  • A malformed declaration is rejected whole, never half-applied, and undeclared tools keep today's behavior byte-identical.

A bid whose value is nested (inside a request body) or derived is outside what a top-level-key declaration can express — use the normalize-and-delegate mureo.policy_gates gate below for those, exactly as for a nested budget.

When a declaration cannot reach your budget

A declaration names a top-level argument key. Two shapes are outside what that can express, and both are ordinary plugin designs (#417):

  • The budget is nested. A native passthrough tool takes the platform's raw request body, so the budget lives at body.daily_budget, not at a top-level key. Declaring body does not help — a key holding a dict is unreadable, and the gate fails closed, refusing every call including the legal ones.
  • The budget is derived. Your mapper computes a figure the caller never typed (say monthly = daily × multiplier) and sends it to the platform. It is not an argument at all, so no key can name it — and it is real spend, so a cap must reach it.

For these, register a gate on the mureo.policy_gates entry-point that normalizes and delegates: project your budget onto the canonical keys mureo already reads, and hand the decision straight back to StrategyPolicyGate. Your gate supplies keys, not a policy — cap comparison, STRATEGY.md parsing, the TTL cache and the fail-closed rules keep a single owner, so upstream fixes to guardrail semantics reach your platform without you touching anything.

# mureo_acme_ads/policy.py
import logging
from typing import Any

from mureo.core.policy import PolicyDecision

logger = logging.getLogger(__name__)

# The keys mureo's built-in scan reads. Normalise to the operator's currency
# units, so a refusal quotes figures they recognise.
_CANONICAL_DAILY = "daily_budget"
_CANONICAL_PERIOD = "lifetime_budget"


def normalize_budget_arguments(
    tool_name: str, arguments: dict[str, Any]
) -> dict[str, float]:
    """Project this call's proposed budgets onto the keys mureo reads.

    Pure; never mutates ``arguments``. Returns ``{}`` when the call proposes
    no budget (a read, a bid change, a partial update that leaves the budget
    alone) so the gate abstains rather than inventing a figure to judge.
    """
    if tool_name != "acme_ads_update_campaign_native":
        return {}
    body = arguments.get("body")
    if not isinstance(body, dict):
        return {}
    daily = body.get("daily_budget")
    if isinstance(daily, bool) or not isinstance(daily, (int, float)):
        return {}
    return {_CANONICAL_DAILY: float(daily)}


class AcmeBudgetPolicyGate:
    """Conforms to ``mureo.core.policy.PolicyGate``. Holds no state."""

    def evaluate(self, tool_name: str, arguments: dict[str, Any]) -> PolicyDecision:
        normalized: dict[str, float] = {}
        try:
            normalized = normalize_budget_arguments(tool_name, arguments)
            if not normalized:
                return PolicyDecision(allowed=True)
            from mureo.policy.strategy_gate import StrategyPolicyGate

            return StrategyPolicyGate().evaluate(
                tool_name, {**arguments, **normalized}
            )
        except Exception:  # the gate ABI is fail-open — but never in silence
            logger.warning(
                "budget guardrail could not be evaluated for '%s'; allowing the "
                "call. The STRATEGY.md cap(s) covering %s are NOT enforced here.",
                tool_name,
                ", ".join(sorted(normalized)) or "(none resolved)",
                exc_info=True,
            )
            return PolicyDecision(allowed=True)
[project.entry-points."mureo.policy_gates"]
acme_budget = "mureo_acme_ads.policy:AcmeBudgetPolicyGate"

Rules for a gate of this kind:

  • Supply keys, never a policy. Do not re-implement cap comparison or refusal messages. The moment your gate decides anything itself, mureo's guardrail semantics are forked and upstream fixes stop reaching you.
  • Pure and fast. It runs on every tool call. No network, no credential access — so a cap needing a figure your tool does not carry and you cannot compute offline (max_daily_budget_increase_pct wants the campaign's current budget; max_total_daily_budget wants an account-wide projection) is simply out of reach. Say so in your docs rather than fetching it.
  • Never raise. The ABI treats a raising gate as broken and abstains anyway; raising deliberately would block every mutation on your platform — an outage dressed up as a guardrail. Log the caps that went unenforced, and allow.
  • Cover every path to the same spend. If two tools reach one budget (an ergonomic wrapper and its native twin), normalize both. A refusal names the cap it hit, so an agent refused on one tool will otherwise retry through the other and commit the identical body.
  • Do not also declare. A declaration replaces the built-in scan for the proposed budgets, which would switch off the very keys your gate injects. Pick one mechanism per tool, and pin the choice with a test.
  • Your module is imported once; your class is constructed per call. mureo enumerates and load()s the entry point on the first dispatch and memoizes the class (#633), so module-level work happens once — but __init__ runs on every tool call and instance attributes never survive it. Keep __init__ empty; put any cross-call cache on a class attribute. Installing or uninstalling your package while a server runs does not change the gate set until restart, exactly as it does not change the tool set.

Structural strategy parity (mutating tools). A mutating plugin call gets the same structural strategy handling as a built-in write: the agent must confirm it with the user and gate it against STRATEGY.md (Operation Mode / Goals) before running it, the call is audited, promoted to action_log, given an observation window for automatic outcome review, and may record a rollback hint — the same channel built-ins use.

What is not generically possible (and is not claimed): mureo's platform-specific analytics (anomaly detection, result_indicator CV-mismatch, RSA-asset audit, rule-based scoring) and executable auto-rollback. Those are hand-written per built-in; matching their depth for your platform requires authoring platform-specific skill logic — mureo cannot synthesise it for an unknown platform.


4. Capabilities

Defined in mureo.core.providers.capabilities.Capability. The enum values are snake_case strings forming a stable ABI — they appear verbatim in skill frontmatter and in entry.capabilities introspection.

The 13 Phase 1 capabilities

Value Meaning
read_campaigns List / read campaign metadata.
read_performance Day-grain performance reporting.
read_keywords List keywords on a campaign.
read_search_terms Read search-term reports (actual user queries).
read_audiences List / read audience metadata.
read_extensions List ad extensions.
write_budget Set daily / lifetime budget on a campaign.
write_bid Set bidding parameters.
write_creative Create / update ads and creatives.
write_keywords Add / pause keywords.
write_audiences Create / remove audiences.
write_extensions Add / remove ad extensions.
write_campaign_status Pause / resume / remove campaigns and ads (covers deletion).

Add the enum members to your capabilities frozenset for the operations your adapter actually supports:

from mureo.core.providers import Capability

capabilities: frozenset[Capability] = frozenset(
    {
        Capability.READ_CAMPAIGNS,
        Capability.READ_PERFORMANCE,
        Capability.WRITE_BUDGET,
        Capability.WRITE_CAMPAIGN_STATUS,
    }
)

Implementing a Protocol vs declaring a Capability

These are independent decisions:

Combination Effect
Implement CampaignProvider AND declare WRITE_BUDGET Skills needing write_budget can run; create_campaign is callable.
Implement CampaignProvider but DON'T declare WRITE_BUDGET Skills needing write_budget see your provider as unavailable.
Don't implement CampaignProvider but declare WRITE_BUDGET The matcher will say "executable" but calls fail at runtime — do not do this.

Rule: only declare a capability if your adapter actually implements the corresponding methods. The matcher trusts your declared set; it does not introspect method bodies.

Capability ↔ method mapping

Capability Methods it gates
READ_CAMPAIGNS list_campaigns, get_campaign, list_ads, get_ad
READ_PERFORMANCE daily_report
READ_KEYWORDS list_keywords
READ_SEARCH_TERMS search_terms
READ_AUDIENCES list_audiences, get_audience
READ_EXTENSIONS list_extensions
WRITE_BUDGET create_campaign, update_campaign (budget field)
WRITE_CREATIVE create_ad, update_ad
WRITE_KEYWORDS add_keywords, set_keyword_status
WRITE_AUDIENCES create_audience, set_audience_status
WRITE_EXTENSIONS add_extension, set_extension_status
WRITE_CAMPAIGN_STATUS set_ad_status, plus campaign-level status writes; covers REMOVED (delete-via-status)
WRITE_BID Reserved for bid-strategy mutations; surfaces in Phase 2.

Parsing capability tokens (for skill frontmatter or config files)

from mureo.core.providers import parse_capability, parse_capabilities

parse_capability("read_campaigns")
# -> Capability.READ_CAMPAIGNS

parse_capabilities(["read_campaigns", "write_budget"])
# -> frozenset({Capability.READ_CAMPAIGNS, Capability.WRITE_BUDGET})

parse_capability("READ_CAMPAIGNS")
# -> ValueError: unknown capability: 'READ_CAMPAIGNS'. Did you mean: read_campaigns? ...

Both helpers raise ValueError with close-match suggestions on unknown tokens, so config-file errors surface with actionable messages.


5. Models: frozen dataclasses and enums

Defined in mureo.core.providers.models. Every entity is @dataclass(frozen=True); every enum is a StrEnum (with a 3.10 backport shim). Collection fields use tuple[T, ...], never list[T], so adapters cannot accidentally hand a mutable container across the Protocol boundary.

Read-side entities

These are what providers return:

Dataclass Used by
Campaign list_campaigns, get_campaign, create_campaign, update_campaign
Ad list_ads, get_ad, create_ad, update_ad, set_ad_status
Keyword list_keywords, add_keywords, set_keyword_status
SearchTerm search_terms
Audience list_audiences, get_audience, create_audience, set_audience_status
Extension list_extensions, add_extension, set_extension_status
DailyReportRow daily_report

Write-side DTOs

These are what providers accept:

Dataclass Used by
CampaignFilters list_campaigns (optional filter argument; all fields optional)
CreateCampaignRequest / UpdateCampaignRequest campaign mutation
CreateAdRequest / UpdateAdRequest ad mutation
KeywordSpec add_keywords (immutable creation spec)
CreateAudienceRequest create_audience
ExtensionRequest add_extension

Enums

Enum Members Notes
CampaignStatus ENABLED, PAUSED, REMOVED REMOVED is the delete signal.
AdStatus ENABLED, PAUSED, REMOVED Same convention.
KeywordStatus ENABLED, PAUSED, REMOVED Same convention.
AudienceStatus ENABLED, REMOVED No PAUSED (audiences cannot be paused).
ExtensionStatus ENABLED, PAUSED, REMOVED Same convention.
ExtensionKind SITELINK, CALLOUT, CONVERSION Type-safe dispatch in list_extensions / add_extension.
KeywordMatchType EXACT, PHRASE, BROAD
BidStrategy MANUAL_CPC, TARGET_CPA, MAXIMIZE_CONVERSIONS, NOT_APPLICABLE NOT_APPLICABLE is for a platform that does not select delivery by a bid — report it instead of the closest-looking auction member. None still means unknown / not fetched, which is a different statement; read-side only (adapters reject it on a create/update request).

Currency and date conventions

  • Money: integer "micros" (1/1,000,000 of the account currency) on every monetary field (daily_budget_micros, cost_micros, cpc_bid_micros). If your platform uses cents or full units, your adapter is responsible for converting at its boundary. A future Money(amount_minor: int, currency: str) abstraction may replace raw _micros fields in Phase 2; the change will be additive.
  • Dates: datetime.date (day-grain in the account's timezone) on every reporting / scheduling field. No int epoch seconds at the Protocol boundary.

Constructing entities

from datetime import date

from mureo.core.providers import (
    Campaign,
    CampaignStatus,
    DailyReportRow,
)

campaign = Campaign(
    id="123",
    account_id="987",
    name="Holiday Sale 2026",
    status=CampaignStatus.ENABLED,
    daily_budget_micros=50_000_000,  # 50 units of account currency
)

row = DailyReportRow(
    date=date(2026, 5, 14),
    impressions=12_345,
    clicks=678,
    cost_micros=4_500_000,
    conversions=12.5,
)

Because every dataclass is frozen, mutating fields raises dataclasses.FrozenInstanceError — use dataclasses.replace(...) to produce a modified copy.


6. Skill matching

Not for platform semantics. Skills are matched on their description and read on demand, so a skill is never guaranteed to be in play while a routine report is written. If what you need to say is "this is how my platform works, do not assume otherwise", register a PlatformModel (Section 3) — that is the always-on route. Skills are for what to do, not for what is true.

mureo ships 16 built-in skills (in mureo/_data/skills/<skill>/SKILL.md) and supports third-party skills via the mureo.skills entry-points group. Each skill is a markdown file with YAML frontmatter declaring what it does and which capabilities it needs.

SKILL.md frontmatter

---
name: my-skill
description: "Run analysis X on the connected ad accounts. Use when the user asks for ..."
capabilities:
  required:
    - read_campaigns
    - read_performance
  advisory_mode:
    - read_campaigns
metadata:
  version: "0.1.0"
---

# My Skill

(prose body — agent reads this as the skill prompt)

Frontmatter keys consumed by the parser:

  • name (required) — matches ^_?[a-z][a-z0-9_-]*$. Hyphens and a single leading underscore are allowed (skill names differ from provider names; see ABI-stability.md for the rationale).
  • description (required) — non-empty string.
  • capabilities.required — list of capability tokens needed for full execution. Empty / absent means the skill is universally executable.
  • capabilities.advisory_mode — list of capability tokens that are sufficient for advisory (read-only) execution. Must be a subset of required.

Any other top-level keys (e.g. metadata) are preserved in SkillEntry.extra for forward compatibility.

Three-bucket classification

mureo.core.skills.match_skills(skills, provider) returns a SkillMatch with three sorted tuples:

Bucket Condition
executable skill.required_capabilities <= provider.capabilities (or skill declares no requirements).
advisory_only Skill is NOT executable AND skill.advisory_mode_capabilities is non-empty AND it is a subset of provider capabilities.
unavailable Otherwise.

The inverse query (providers_for_skill(skill, registry)) returns a ProviderMatch with the same three buckets, but enumerating providers instead of skills.

Worked example

from mureo.core.providers import (
    Capability,
    discover_providers,
    get_provider,
)
from mureo.core.skills import discover_skills, match_skills

discover_providers()
discover_skills()

provider = get_provider("acme_ads")
skills = discover_skills()
match = match_skills(skills, provider)

print("Executable:", [s.name for s in match.executable])
print("Advisory:  ", [s.name for s in match.advisory_only])
print("Unavail.:  ", [s.name for s in match.unavailable])

Skill name vs provider name regex (do not confuse them)

Identifier Regex Example
Provider name ^[a-z][a-z0-9_]*$ acme_ads
Skill name ^_?[a-z][a-z0-9_-]*$ _mureo-shared, daily-check

The skill regex is deliberately looser because the 16 in-tree skills already use hyphens and a single leading underscore (_mureo-shared, daily-check, weekly-report). Provider names map to Python tool prefixes and must remain snake_case identifiers.


7. Distribution patterns

mureo discovers providers via Python's standard entry-points mechanism, so any installation channel pip supports is automatically supported here. Four common patterns:

7.1 Public PyPI

The simplest path. Publish your plugin to PyPI; users install with:

pip install mureo-acme-ads

After installation, mureo picks up the mureo.providers entry point automatically on the next discover_providers() call. No mureo config change is required.

7.2 Private package index

For closed-source platforms or enterprise deployments. Host your package on a private PyPI / GitHub Packages / AWS CodeArtifact / Artifactory / etc., then:

pip install --index-url https://pypi.example.com/simple/ mureo-acme-ads
# or:
pip install --extra-index-url https://pypi.example.com/simple/ mureo-acme-ads

The plugin still goes through the same entry-points group; mureo does not distinguish public-PyPI plugins from private-index plugins at discovery time. ProviderEntry.source_distribution will be the PEP 503 normalized name of the package (mureo-acme-ads) regardless of where it came from.

7.3 Direct from Git

For pre-release plugins, monorepos, or forks:

pip install git+https://github.com/your-org/mureo-acme-ads.git@v0.1.0
pip install git+ssh://git@github.com/your-org/mureo-acme-ads.git@main
pip install git+https://github.com/your-org/mureo-acme-ads.git@<commit-sha>

Pinning to a tag or commit SHA is the recommended pattern — @main can drift unexpectedly.

7.4 Vendored wheel / local path

For air-gapped environments or hermetic builds:

# Build once
pip wheel mureo-acme-ads -w ./wheels

# Install from the local wheel
pip install ./wheels/mureo_acme_ads-0.1.0-py3-none-any.whl
# or editable from source:
pip install -e ./path/to/mureo-acme-ads

This is also the recommended pattern during development — pip install -e . gives you live re-loads without re-publishing.

Choosing a pattern

Constraint Recommended pattern
Open-source plugin, widest reach Public PyPI (7.1)
Closed-source, internal-only Private index (7.2)
Pre-release, multiple stakeholders Git URL pinned to commit SHA (7.3)
Air-gapped / regulated environment Vendored wheel (7.4)
Active development Editable install (pip install -e .)

All four patterns share the same entry-points contract — your pyproject.toml does not change.


8. Entry-points registration

mureo iterates the entry-points group named mureo.providers at discovery time. The group name is a fixed ABI constant (mureo.core.providers.registry.PROVIDERS_ENTRY_POINT_GROUP).

Basic registration

[project.entry-points."mureo.providers"]
acme_ads = "mureo_acme_ads.adapter:AcmeAdsAdapter"

The key (acme_ads) is what mureo passes to Registry._load_entry_point as ep.name. The value is a standard Python entry-point target (<module>:<attribute>). The attribute must resolve to a class, not an instance — mureo defers instantiation so plugin __init__ side effects (network, FS, credential loading) do not run during discovery.

The entry-point key should match the class's name class attribute. mureo trusts the class attribute (not the entry-point key) when populating ProviderEntry.name, but mismatches are confusing for operators reading discovery logs.

Registering multiple providers from one package

A single package can ship multiple providers. Use unique entry-point keys and ensure each class has a unique name attribute:

[project.entry-points."mureo.providers"]
acme_ads = "mureo_acme.adapters:AcmeAdsAdapter"
acme_search = "mureo_acme.adapters:AcmeSearchAdapter"

First-wins on duplicate names

If two packages register a provider class with the same name, the first registered wins and mureo emits a RegistryWarning for the loser. This is a security property: a malicious package installed after a legitimate one cannot silently take over its slot. To detect duplicates in CI:

import warnings

from mureo.core.providers import RegistryWarning, discover_providers

warnings.filterwarnings("error", category=RegistryWarning)
discover_providers()  # raises on first duplicate / malformed plugin

Programmatic registration (tests, embedded use)

For tests and embedded scenarios you can also register a class in process without going through entry points:

from mureo.core.providers import register_provider_class

entry = register_provider_class(MyAdapter, source_distribution="my-package")

register_provider_class raises on validation failure (it is the strict counterpart to entry-points discovery, which warns + skips). Use it for fast feedback during development.


9. Shipping skills with your plugin

If your plugin wants to ship its own SKILL.md files (for workflows specific to your platform), register a directory under the mureo.skills entry-points group.

pyproject.toml

[project.entry-points."mureo.skills"]
mureo-acme-ads = "mureo_acme_ads.skills:SKILLS_DIR"

[tool.hatch.build.targets.wheel]
packages = ["mureo_acme_ads"]

mureo_acme_ads/skills/__init__.py

"""Directory locator for mureo.skills entry-point."""

from pathlib import Path

SKILLS_DIR: Path = Path(__file__).resolve().parent

Directory layout

mureo_acme_ads/skills/
├── __init__.py
├── acme-budget-rebalance/
│   └── SKILL.md
└── acme-creative-audit/
    └── SKILL.md

Each SKILL.md file is parsed by mureo.core.skills.parse_skill_md. The discovery walker:

  • recursively scans up to 4 directory levels deep,
  • accepts at most 64 SKILL.md files per entry-point root,
  • rejects files larger than 64 KiB,
  • enforces UTF-8 strict decoding,
  • uses yaml.safe_load only (never yaml.load),
  • refuses symlinks that escape the entry-point root.

A malformed SKILL.md is skipped with a SkillDiscoveryWarning; the rest of the plugin's skills still load.

First-wins on duplicate skill names

Like providers, skill discovery is first-wins. A built-in skill named daily-check cannot be silently replaced by a third-party plugin. The shadow attempt emits a SkillDiscoveryWarning. Pick plugin-prefixed skill names (e.g. acme-budget-rebalance) to avoid collisions.

Native slash skills (mureo.native_skills)

The mureo.skills group above is for context skills — discovered and matched at runtime; they are never copied to ~/.claude/skills and so do not appear as a /slash command. If you want your plugin to ship a native slash skill — a /<name> workflow the operator runs explicitly — register the directory under the separate mureo.native_skills group instead. The group name is a fixed ABI constant (mureo.core.providers.registry.NATIVE_SKILLS_ENTRY_POINT_GROUP).

[project.entry-points."mureo.native_skills"]
mureo-acme-ads = "mureo_acme_ads.native_skills:SKILLS_DIR"

The value is a module-level Path pointing at a directory of <skill>/SKILL.md subdirs (same shape and locator pattern as the mureo.skills example above). mureo deploys those subdirs alongside its own bundle into ~/.claude/skills and ~/.codex/skills during setup (mureo setup claude-code / codex, mureo configure) and re-deploys them on mureo upgrade.

Guarantees (mureo.cli.native_skills):

  • Built-in-first — a native skill whose directory name collides with a mureo bundle skill is skipped; a plugin can never shadow a core /slash.
  • First-wins between plugins — the earliest contributor of a given skill name wins; later duplicates are skipped with a NativeSkillDeployWarning. Prefix your skill names (acme-…) to avoid collisions with the bundle and with other plugins.
  • Fault isolation — a broken entry point (load error, non-directory value, missing dir, escaping symlink) is warned and skipped; the rest still deploy, and setup/upgrade never fail because of a plugin.
  • Plugin-owned removalremove_native_skills only removes names the currently installed plugins contribute (minus any bundle collision), so it never touches the bundle or user-authored skills.

Choose the group by intent: mureo.skills for platform-bound context the agent consults, mureo.native_skills for operator-invoked /slash workflows.


10. Security considerations

Plugin-side responsibilities

Your plugin runs inside the mureo process. The mureo project treats plugins as a known trust boundary but still expects basic hygiene:

  1. Never hardcode secrets. Load credentials from environment variables, a credentials file under ~/.mureo/, or a system secret store. mureo itself loads ~/.mureo/credentials.json for built-in adapters; reuse that path or pick a clearly-namespaced alternative (~/.mureo/<plugin>.json).
  2. Validate inputs at adapter boundaries. Your adapter receives user-controlled values via DTOs (campaign IDs, keyword text, URLs). Validate them before interpolating into platform-specific query languages — see GoogleAdsAdapter._validate_campaign_id for the digits-only GAQL-safety pattern.
  3. Do not perform I/O at import time. mureo discovery loads your top-level module via ep.load() and then calls validation on the class object. Module import should be cheap. Defer credential loading, network calls, and FS scans to __init__ or first-method-call time.
  4. Bound external calls. Use timeouts on every HTTP call, bounded retries with jitter, and rate limits matching the platform's quota.
  5. Never trust third-party data as code. If your adapter consumes user-supplied YAML/JSON/HTML, use safe_load / parameterized parsers — never eval, exec, or yaml.load.
  6. Log securely. Do not log access tokens, refresh tokens, developer tokens, or PII. Mask secrets even in debug logs.

What mureo does on your behalf

  1. Per-plugin fault isolation. A broken ep.load() or a malformed class is caught by a per-entry try/except and surfaced as a RegistryWarning. One bad plugin cannot break discovery of the others.
  2. Class validation before registration. Bad name / bad capabilities / wrong types are rejected with a clear error message embedding the class's __qualname__.
  3. Deferred instantiation. mureo registers your class object, not an instance. Your __init__ side effects do not run during discovery.
  4. First-wins on duplicate names. A late-arriving malicious plugin cannot shadow an earlier legitimate one.
  5. Source-distribution tracking. Every ProviderEntry records the PEP 503 normalized package name in source_distribution, so operators can identify which package supplied each provider. Treat the value as untrusted display data — do not interpolate it into shell / SQL / log-injection-sensitive sinks downstream.
  6. Strict-mode escape hatch. Setting warnings.filterwarnings("error", category=RegistryWarning) (or the same for SkillDiscoveryWarning) turns the first malformed plugin into a startup failure — useful in CI and in security-conscious deployments.

What mureo does NOT do (Phase 1)

  • No code signing / signature verification. Plugin authenticity is the user's responsibility — they install you via pip, the same trust model as any pip package.
  • No sandboxing. Your code runs with the same OS privileges as mureo itself. Operate accordingly.
  • No automatic secret redaction. If your plugin logs secrets, they appear in mureo's logs verbatim.

11. End-to-end example

A complete plugin skeleton is reproduced below. It implements one domain Protocol, ships one skill, and is ready to publish.

Repository layout

mureo-acme-ads/
├── pyproject.toml
├── README.md
├── LICENSE
├── mureo_acme_ads/
│   ├── __init__.py
│   ├── adapter.py
│   ├── client.py
│   ├── mappers.py
│   └── skills/
│       ├── __init__.py
│       └── acme-budget-audit/
│           └── SKILL.md
└── tests/
    ├── test_adapter_protocol.py
    └── test_discovery.py

pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mureo-acme-ads"
version = "0.1.0"
description = "ACME Ads provider and skills for mureo"
requires-python = ">=3.10"
license = "Apache-2.0"
authors = [{name = "Your Name"}]
dependencies = [
    "mureo>=0.8,<1",
    "httpx>=0.27,<1",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4,<9",
    "mypy>=1.8,<2",
    "ruff>=0.3,<1",
]

[project.entry-points."mureo.providers"]
acme_ads = "mureo_acme_ads.adapter:AcmeAdsAdapter"

[project.entry-points."mureo.skills"]
mureo-acme-ads = "mureo_acme_ads.skills:SKILLS_DIR"

[tool.hatch.build.targets.wheel]
packages = ["mureo_acme_ads"]

mureo_acme_ads/adapter.py

"""ACME Ads adapter — CampaignProvider implementation."""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable
from datetime import date
from typing import TypeVar

from mureo.core.providers import (
    Ad,
    AdStatus,
    Campaign,
    CampaignFilters,
    Capability,
    CreateAdRequest,
    CreateCampaignRequest,
    DailyReportRow,
    UpdateAdRequest,
    UpdateCampaignRequest,
)

from mureo_acme_ads.client import AcmeAdsClient
from mureo_acme_ads.mappers import to_ad, to_campaign, to_daily_row

_T = TypeVar("_T")


class AcmeAdsAdapter:
    """ACME Ads CampaignProvider with deferred async bridge."""

    name: str = "acme_ads"
    display_name: str = "ACME Ads"
    capabilities: frozenset[Capability] = frozenset(
        {
            Capability.READ_CAMPAIGNS,
            Capability.READ_PERFORMANCE,
            Capability.WRITE_BUDGET,
            Capability.WRITE_CREATIVE,
            Capability.WRITE_CAMPAIGN_STATUS,
        }
    )

    def __init__(self, client: AcmeAdsClient) -> None:
        self._client = client

    @staticmethod
    def _run(coro: Awaitable[_T]) -> _T:
        return asyncio.run(coro)

    def list_campaigns(
        self, filters: CampaignFilters | None = None
    ) -> tuple[Campaign, ...]:
        raw = self._run(self._client.list_campaigns(filters))
        return tuple(to_campaign(r) for r in raw)

    def get_campaign(self, campaign_id: str) -> Campaign:
        raw = self._run(self._client.get_campaign(campaign_id))
        return to_campaign(raw)

    # ... remaining methods elided for brevity; same shape.
    def create_campaign(self, request: CreateCampaignRequest) -> Campaign:
        raise NotImplementedError

    def update_campaign(
        self, campaign_id: str, request: UpdateCampaignRequest
    ) -> Campaign:
        raise NotImplementedError

    def list_ads(self, campaign_id: str) -> tuple[Ad, ...]:
        raw = self._run(self._client.list_ads(campaign_id))
        return tuple(to_ad(r) for r in raw)

    def get_ad(self, campaign_id: str, ad_id: str) -> Ad:
        raise NotImplementedError

    def create_ad(self, campaign_id: str, request: CreateAdRequest) -> Ad:
        raise NotImplementedError

    def update_ad(
        self, campaign_id: str, ad_id: str, request: UpdateAdRequest
    ) -> Ad:
        raise NotImplementedError

    def set_ad_status(
        self, campaign_id: str, ad_id: str, status: AdStatus
    ) -> Ad:
        raise NotImplementedError

    def daily_report(
        self, campaign_id: str, start_date: date, end_date: date
    ) -> tuple[DailyReportRow, ...]:
        raw = self._run(
            self._client.daily_report(campaign_id, start_date, end_date)
        )
        return tuple(to_daily_row(r) for r in raw)

mureo_acme_ads/skills/acme-budget-audit/SKILL.md

---
name: acme-budget-audit
description: "Audit ACME Ads campaigns for over-budget waste. Use when the user asks for an ACME budget review or efficiency check."
capabilities:
  required:
    - read_campaigns
    - read_performance
  advisory_mode:
    - read_campaigns
metadata:
  version: "0.1.0"
---

# ACME Budget Audit

(skill body)

tests/test_adapter_protocol.py

"""Verify adapter satisfies the Protocol structurally."""

from mureo.core.providers import (
    BaseProvider,
    CampaignProvider,
    validate_provider,
)

from mureo_acme_ads.adapter import AcmeAdsAdapter


def test_satisfies_base_provider() -> None:
    # Class-level isinstance against the runtime-checkable Protocol.
    assert isinstance(AcmeAdsAdapter, type)
    validate_provider(AcmeAdsAdapter)  # raises on contract failure


def test_satisfies_campaign_provider() -> None:
    # Instance-level structural check.
    client = ...  # mock
    adapter = AcmeAdsAdapter(client)
    assert isinstance(adapter, CampaignProvider)
    assert isinstance(adapter, BaseProvider)

12. Troubleshooting

My plugin is not discovered

  1. Confirm pip show mureo-acme-ads lists your package as installed.
  2. Confirm the entry-points group is exactly mureo.providers:
    python -c "from importlib.metadata import entry_points; print(list(entry_points(group='mureo.providers')))"
  3. Enable strict mode and run discovery to see why it was skipped:
    import warnings
    from mureo.core.providers import RegistryWarning, discover_providers
    warnings.filterwarnings("error", category=RegistryWarning)
    discover_providers(refresh=True)
  4. Common causes: name does not match ^[a-z][a-z0-9_]*$, capabilities is a plain set instead of frozenset, display_name is empty, top-level import in your module raises.

My skill is loaded but the matcher says unavailable

  1. Check the provider's declared capabilities:
    from mureo.core.providers import get_provider
    print(sorted(str(c) for c in get_provider("acme_ads").capabilities))
  2. Check the skill's declared requirements:
    from mureo.core.skills import discover_skills
    for s in discover_skills():
        if s.name == "my-skill":
            print(sorted(str(c) for c in s.required_capabilities))
  3. The skill is executable only if the provider's set is a superset of the skill's required set.

Discovery is slow / floods warnings

A hostile environment (many malformed plugins) can produce many RegistryWarning entries. In production sinks, rate-limit at the log layer or enable strict mode so the first malformed plugin is a hard failure. Phase 2 may add in-module rate limiting; see mureo/core/providers/registry.py module docstring.

How do I clear the discovery cache (tests)?

from mureo.core.providers import clear_registry
from mureo.core.skills import clear_skills_cache

clear_registry()
clear_skills_cache()

Both wipe the in-process cache; the next discover_* call re-iterates entry points.


13. Web extensions

Configure-UI extensions let a plugin add tabs / API routes to the mureo configure wizard without each surface having to know about the plugin. The mechanism mirrors §3 (provider Protocols) and §8 (entry-points registration): you implement a small WebExtension Protocol, register it under a dedicated entry-point group, and the configure server picks it up at startup.

When to use a web extension

  • Setup UI for an alternate backend the user must configure (Vault / cloud secret manager credentials, custom state store connection strings).
  • A connection-test or diagnostic panel that operators run from the same wizard they use for the built-in setup.
  • Any custom data source / panel that benefits from being reachable inside mureo configure rather than via a separate CLI.

The mechanism only covers the configure wizard. For MCP tools see §3 (provider Protocols); for slash-command skills see §9.

WebExtension Protocol

# my_plugin/web_extension.py
from typing import Any

from mureo.web.extensions import (
    RouteContribution,
    StaticAsset,
    ViewContribution,
)


def _ping(_request: Any, payload: dict[str, Any]) -> None:
    from mureo.web._helpers import send_json
    send_json(_request, {"echo": payload})


class MyExtension:
    name = "acme-vault"
    display_name = "Acme Vault setup"

    def routes(self) -> tuple[RouteContribution, ...]:
        return (
            RouteContribution(method="GET", subpath="/ping", handler=_ping),
        )

    def view(self) -> ViewContribution | None:
        return ViewContribution(
            html_fragment=(
                '<section><h2>Acme Vault</h2>'
                '<button id="acme-vault-ping">Ping</button></section>'
            ),
            scripts=(
                StaticAsset(
                    filename="acme-vault.js",
                    content_type="application/javascript",
                    body=(
                        b"document.getElementById('acme-vault-ping')"
                        b".addEventListener('click', () =>"
                        b" fetch('/api/ext/acme-vault/ping?x=1')"
                        b".then(r => r.json()).then(console.log));"
                    ),
                ),
            ),
        )

Localising the nav-tab label

display_name is the fallback label and is always required. To follow the same convention as the built-in nav tabs (Setup / Demo / BYOD / Danger Zone — translated via data-i18n keys in i18n.json), declare an optional display_name_i18n class attribute keyed by BCP-47 language code:

class MyExtension:
    name = "acme-vault"
    display_name = "Acme Vault setup"          # fallback for any locale
    display_name_i18n = {                       # optional, per-locale labels
        "en": "Acme Vault",
        "ja": "Acme Vault 設定",
    }
    # ...routes() / view() unchanged

Lookup priority on the renderer side: display_name_i18n[active_locale]display_name_i18n["en"]display_name. Operators who toggle the configure-UI locale see your tab name update without a page reload — the renderer listens for mureo:locale_changed. Extensions that do not declare display_name_i18n keep the legacy behaviour: a single string shown verbatim in every locale.

The attribute is optional: it is read via getattr so the WebExtension Protocol itself stays unchanged and pre-feature extensions continue to load without modification. The value must be a Mapping[str, str] — anything else (a list of pairs, a key of type int, a value of type int, etc.) is a packaging bug and the extension is skipped at discovery with a WebExtensionWarning.

Currently the configure-UI ships an en / ja locale toggle; if you add labels for other BCP-47 codes they are stored and surfaced via /api/extensions but never selected by the built-in UI today. The contract is forward-compatible — if mureo grows more locales later, your existing entries Just Work.

Empty-string values ({"ja": ""}) are treated as missing — the renderer's i18n[locale] || i18n.en || display_name chain skips falsy entries and falls through to the next candidate. If you want to ship an intentionally blank label, render an explicit zero-width character (e.g. "​") instead of "".

Taking over built-in surfaces (full-surface plugins)

Most extensions add a tab next to the built-in ones. A plugin that supplies a complete alternative operator experience — its own setup flow, credential model, and dashboard — can additionally declare two optional class attributes (both read via getattr; pre-feature extensions keep loading unchanged):

class MyExtension:
    name = "acme-suite"
    display_name = "Acme Suite"
    # Hide the built-in tabs this plugin supersedes. Valid keys:
    # "setup", "demo", "byod", "danger".
    hidden_builtin_tabs = ("setup", "demo")
    # Skip the built-in landing; the operator lands on this
    # extension's view directly. Requires a non-None view().
    replaces_landing = True
    # ...routes() / view() unchanged

Validation discipline (mirrors display_name_i18n):

  • Type-level problems are packaging bugs — a non-tuple hidden_builtin_tabs, a non-str element, or a non-bool replaces_landing skips the whole extension at discovery with a WebExtensionWarning.
  • Value-level problems are soft — an unknown tab key is dropped with a warning (the extension survives); replaces_landing=True without a view() is downgraded to False with a warning (there would be nowhere for the operator to land).
  • At most one landing owner — when several installed extensions set replaces_landing=True, the first-discovered one wins and the rest are downgraded with a warning (mirrors the duplicate-name discipline).

Renderer behaviour: hidden tabs disappear from the dashboard nav and their panes are never shown; if a hidden tab would have been the default selection (Setup), the landing-owning extension's tab — or, absent one, the first extension tab — is selected instead. A headless extension that hides tabs without shipping any view falls back to the first still-visible built-in tab, so the pane is never orphaned; if you hide Setup you should normally ship a view() for the operator to land on. With replaces_landing, the built-in landing is skipped on first load and the operator is taken straight to the dashboard with your view active. Both attributes ride /api/extensions as hidden_builtin_tabs (list) and replaces_landing (bool) — always present, no existence check needed client-side.

Dashboard cards (contributing to built-in groups)

Sometimes a plugin's setting belongs next to an existing built-in card rather than on a tab of its own — e.g. a companion write-side setting rendered beside the built-in "External advisor MCP" card. For that, declare the optional dashboard_cards() method (read via getattr; pre-feature extensions keep loading unchanged):

from mureo.web.extensions import DashboardCard, StaticAsset

class MyExtension:
    name = "acme-advisor"
    display_name = "Acme Advisor"

    def routes(self):
        ...  # the card's form posts to your /api/ext/<name>/ routes

    def view(self):
        return None  # cards do NOT require a view/tab

    def dashboard_cards(self) -> tuple[DashboardCard, ...]:
        return (
            DashboardCard(
                group="advanced",  # must be in BUILTIN_CARD_GROUPS
                html_fragment=(
                    '<section class="dashboard-section" data-acme-advisor>'
                    "<h2>Acme advisor</h2>"
                    "</section>"
                ),
                scripts=(
                    StaticAsset(
                        filename="acme-card.js",
                        content_type="application/javascript",
                        body=_CARD_JS,
                    ),
                ),
            ),
        )

Contract:

  • group must be one of BUILTIN_CARD_GROUPS ("advanced" for an operator setting beside a built-in settings card, or "reports" for a reporting action beside the built-in report cards) — a fixed allowlist, mirroring the hidden_builtin_tabs discipline, so plugins never couple to arbitrary internals of the app layout. An unknown group raises at DashboardCard construction and skips the extension. A card in a group a given mureo does not yet accept (e.g. "reports" before this release) raises ValueError, so version-gate a new-group card on the mureo release that added it.
  • The fragment obeys the same sanitisation as view() — no inline <script> / <style> / on*= / javascript:; ship behaviour and styling as StaticAsset scripts / styles, served from GET /static/ext/<name>/<filename> alongside your view assets. Keep asset filenames unique across your extension (view + cards share one URL namespace; a duplicate filename shadows the other).
  • Unlike a view, cards are injected eagerly when extension discovery renders (a built-in tab has no per-plugin click hook), so card scripts must be idempotent and cheap to load.
  • dashboard_cards() is called exactly once during discovery, like view(). A non-callable attribute, non-tuple return, or foreign element skips the whole extension with a WebExtensionWarning.
  • The index payload carries the cards as dashboard_cards (always present, [] when none): one {group, html_fragment, scripts, styles} object per card.

pyproject.toml

[project.entry-points."mureo.web_extensions"]
acme-vault = "my_plugin.web_extension:MyExtension"

The entry-point value can resolve to either:

  • the class (instantiated zero-arg by the loader; the example above), or
  • a callable that returns an instance.

Discovery happens once at ConfigureWizard construction; if your plugin is installed (pip install) and your entry-point resolves cleanly, the extension appears as an extra tab in the dashboard the next time the user runs mureo configure.

URL surface

URL Notes
GET /api/extensions Index consumed by the front-end renderer.
GET /api/ext/<name>/<subpath> Your GET routes. Payload is the flattened query string.
POST /api/ext/<name>/<subpath> Your POST routes. Body is the parsed JSON object. CSRF + Host gate already applied.
GET /static/ext/<name>/<filename> Your StaticAsset bodies, served verbatim with the Content-Type you declared.

Subpaths and filenames are regex-validated at both registration and dispatch (NAME_PATTERN / SUBPATH_PATTERN / FILENAME_PATTERN in mureo.web.extensions): the dispatcher refuses subpaths containing .., double-slash, trailing slash, ?, or #, and filenames containing directory separators or starting with a dot. Real query strings appear AFTER the URL path and are handled by the dispatcher's _flatten_query helper (first-value-wins).

Security model

Every response inherits the configure-UI Content-Security-Policy (default-src 'none'; script-src 'self'; style-src 'self'). Your extension UI MUST therefore ship its JavaScript and CSS via StaticAsset (served from /static/ext/<name>/<file>, same origin as the CSP allows) and MUST NOT embed inline <script> / <style> / on*= event handlers / javascript: URLs in html_fragment. Discovery rejects html fragments that include any of these patterns so the failure surfaces explicitly instead of producing a silently inert UI under the CSP.

CSRF protection applies automatically to your POST routes: the dispatcher runs the same Host-header + CSRF check the built-in routes use before calling your handler, so you do not need to add any token plumbing.

Handler exceptions are caught by the dispatcher and surfaced as a generic 500 {"error": "extension_handler_error"} JSON envelope; the exception repr is logged server-side only (it may contain secrets the handler touched). Your handler MUST NOT raise after starting to write the response — the dispatcher cannot retroactively suppress a partial response that has already shipped bytes to the wire.

Lazy loading

The configure UI fetches /api/extensions once when the dashboard opens and renders one tab per extension. The html_fragment and scripts / styles of any specific extension are only injected into the DOM the first time the user clicks that extension's tab. Operators who never visit your tab pay zero extra page weight; once visited, your assets persist for the rest of the configure session.

Debugging discovery

  • pip show <your-dist> confirms the install.
  • importlib.metadata.entry_points(group="mureo.web_extensions") lists the registered entry points in the running interpreter.
  • mureo configure --log-level=DEBUG (if your shell respects it via MUREO_LOG_LEVEL=DEBUG mureo configure) surfaces WebExtensionWarning messages explaining why an entry point was skipped (bad name, broken ep.load(), routes() / view() exception, duplicate name shadowed by an earlier registration).

Strict-mode deployments can convert warnings into hard failures with:

import warnings
from mureo.web.extensions import WebExtensionWarning

warnings.filterwarnings("error", category=WebExtensionWarning)

Programmatic registration (tests)

from mureo.web.extensions import reset_web_extensions

# clear the process-wide cache so the next discover_web_extensions()
# call re-iterates entry points (which your test fixtures might have
# monkeypatched).
reset_web_extensions()

14. Shipping analytics with your plugin

Status: Issue #120 Phase 1/2. Audience: plugin authors who want to give their platform the same deep-analytics treatment mureo applies to its built-in google_ads / meta_ads adapters — anomaly detection, performance diagnosis, creative audit, budget efficiency. Skills consume the contract uniformly; built-ins and plugins look identical to a workflow.

Why this is opt-in (and why mureo cannot auto-derive it)

An external integration — a plugin or an official MCP — exposes only tool names, input schemas, free-text descriptions, and opaque result blobs. mureo cannot synthesize platform-specific heuristics from that surface: metric semantics, entity relationships, conversion taxonomy, billing/delivery model, and failure modes are platform-specific domain knowledge. Auto-generated analytics would fabricate plausible- but-wrong analysis and violate mureo's trustworthiness principle.

Therefore: analytics modules are hand-authored per platform. A plugin that does not ship one is fully supported — skills detect the absence and emit analytics_not_available_for_<platform> rather than guessing.

The AnalyticsModule Protocol

A plugin ships one class with these members (see mureo/analytics/protocol.py for the runtime-checked Protocol):

Member Required Purpose
platform: str (class attribute) yes Registry name — the stable identifier this module registers itself under, conventionally your provider's name. It is not the STATE.json platform key; see Canonical platform key below.
capabilities() -> frozenset[AnalyticsCapability] yes Which of the four methods this module actually supports.
async detect_anomalies(account_id, *, window_days=7) -> tuple[Anomaly, ...] when capability advertised Anomaly detection over the trailing window. MUST gate by sample size.
async diagnose_performance(account_id, *, scope) -> PerformanceDiagnosis when capability advertised Headline + findings + structured metrics.
async audit_creative(account_id) -> CreativeAudit when capability advertised RSA / image / video / copy audit.
async analyze_budget_efficiency(account_id) -> BudgetEfficiency when capability advertised Per-campaign efficiency score + reallocation suggestion.

Unsupported methods MUST raise NotImplementedError (the registry does not stub them). Skills consult capabilities() and skip the call when the capability is absent, so the exception is a defensive fallback for misbehaving callers, not the primary signalling channel.

pyproject.toml — entry point

[project.entry-points."mureo.analytics"]
acme_ads = "mureo_acme_ads.analytics:AcmeAdsAnalyticsModule"

Optional extension: DeliveryCollapseModule (#546)

Delivery collapse — a campaign still set to serve whose impressions have gone to zero — is detected by shared core code; a platform only has to supply day-grain delivery. Opt in by implementing a second Protocol alongside AnalyticsModule:

from mureo.analysis.delivery_collapse import delivery_series_from_rows
from mureo.analytics import (
    AnalyticsCapability,
    DeliveryCollapseModule,
    DeliveryCollapseReport,
)
from mureo.analysis.delivery_collapse import detect_delivery_collapses


class AcmeAdsAnalyticsModule:
    platform = "acme_ads"

    def capabilities(self):
        return frozenset({AnalyticsCapability.DETECT_DELIVERY_COLLAPSE})

    async def detect_delivery_collapse(
        self, account_id, *, history_days=60, thresholds=None, as_of=None
    ) -> DeliveryCollapseReport:
        rows = await self._daily_delivery(account_id, days=history_days)
        series = delivery_series_from_rows(rows, platform=self.platform)
        return DeliveryCollapseReport(
            platform=self.platform,
            account_id=account_id,
            status="ok",
            evaluated_campaigns=len(series),
            signals=detect_delivery_collapses(
                series, thresholds=thresholds, as_of=as_of
            ),
        )

Each row is {campaign_id, campaign_name, status, end_date, date, impressions, clicks, cost}; status is your platform's own spelling (mureo recognises ENABLED / ACTIVE / ENABLE / RUNNING / SERVING / DELIVERING / ELIGIBLE as "should be serving"). Do not write your own threshold logic — detect_delivery_collapses already handles the weekday-aware baseline, the partial-day exclusion and the operator's ## Guardrails overrides, and its false-positive behaviour is what makes the detector usable unattended.

status is not decoration. If you cannot produce day-grain delivery, return status="data_unavailable" with a detail, and status="no_credentials" when credentials are missing. Returning status="ok" with an empty signals tuple means checked, everything is healthy — using it for "could not check" is a false all-clear on an account that may be entirely dead.

AnalyticsModule is runtime_checkable, so this method is deliberately NOT a member of it: adding one would break isinstance for every existing four-method module. See docs/ABI-stability.md §Analytics.

pyproject.toml — entry point (continued)

mureo.analytics is independent of mureo.providers: a package may ship a provider only, an analytics module only, or both. The two groups have separate discovery paths and separate fault isolation.

Canonical platform key (Issues #481, #537)

Your plugin platform has exactly one key that mureo joins on: plugin:<distribution>:<provider>, built from your pip distribution name and the entry-point name that platform is registered under (mureo-acme-ads + acme_adsplugin:mureo-acme-ads:acme_ads). It is the STATE.json platforms key, the platform value on promoted action_log entries, the key the reporting dashboard resolves a display label from, and the platform field mureo_analytics_modules_list reports — so a skill holding a STATE.json key finds your analytics.

Why both halves. One distribution may ship several platforms: mureo-lineyahoo-bridge registers line_ads, yahoo_ads and yahoo_ads_display. Keyed on the distribution alone (the original #481 form) all three collapse onto one key, so a writer files three platforms' spend under one entry — one platform's numbers recorded as another's — and the key cannot be resolved back to which platform it meant.

The shape never depends on how many platforms you happen to ship. A single-platform distribution gets the same two-part key as a three-platform one. Deriving the shape from that count would silently change your first platform's key the day you add a second, breaking joins for data already written under it.

: is safe as the separator, but not because both halves are forbidden from containing one. The distribution half always comes from your installed package metadata, and a pip distribution name is ASCII letters, digits, -, _ and . only (PEP 503 / PEP 508), so it can never forge a separator. The provider half is validated only on the mureo.providers path (^[a-z][a-z0-9_]*$); an entry-point name may legally contain :importlib.metadata splits each line of entry_points.txt on the first = only — and an AnalyticsModule's platform is not pattern-checked at all. What makes that safe is that mureo splits on the first : after the prefix: the distribution is always unambiguous, and a colon-bearing provider round-trips verbatim rather than being truncated. Use a plain snake_case name anyway — it is what the two entry-point groups have to agree on.

Name your provider and your analytics module identically. Your analytics module's platform attribute is its registry name, and it is the <provider> half of the key. If your distribution ships both a mureo.providers provider and a mureo.analytics module for the same platform, both entry points must use the same name — otherwise the dispatch path and the analytics listing build two different keys for one platform. mureo_analytics_modules_list still reports the registry name separately as registry_name (with source_distribution alongside); neither is a key on its own, and nothing persists them.

The plugin: namespace is reserved: a module whose platform starts with plugin: is refused at registration (with an AnalyticsModuleWarning) and never reaches the registry, because such a name could shadow another distribution's canonical key. Use a plain registry name — acme_ads, not plugin:mureo-acme-ads.

Shipping several analytics modules from one distribution is fine — each gets its own key. mureo logs a warning naming those keys, because the legacy short form (below) cannot name one of them.

You do not build the key yourself: mureo builds it from the distribution and entry-point name that shipped the plugin. If you write STATE.json through mureo's tools, use the platform value mureo_analytics_modules_list reported, verbatim — mixing identifiers is exactly the silent join failure this convention prevents. See mureo/core/platform_keys.py.

The legacy plugin:<distribution> form

The #481 key stays valid on read everywhere: mureo_analytics_run accepts it, the write guards accept it, and the dashboard labels it exactly as before. For a distribution that provides a single platform the two forms denote the same platform, so state already written under the short form keeps joining.

For a distribution that provides several, the short form is genuinely ambiguous — it names a package, not a platform. mureo_analytics_run resolves it to the alphabetically-first registry name (unchanged from #481) and logs a warning naming the unambiguous keys.

mureo performs no migration. It does not merge, drop or rewrite an operator's state entries — the two halves of a duplicate typically hold different partial figures, so repairing one is the operator's call. Writing an account under the new key when it is already held under the legacy key is refused by the #534 write guard, and a document that already carries both surfaces as a duplicate_account conflict on the Reports card. Neither form is deprecated on read.

Minimal example

# mureo_acme_ads/analytics.py
from mureo.analytics import (
    AnalyticsCapability,
    Anomaly,
    AnomalySeverity,
    BudgetEfficiency,
    CreativeAudit,
    PerformanceDiagnosis,
    PerformanceScope,
)


class AcmeAdsAnalyticsModule:
    platform = "acme_ads"

    _SUPPORTED = frozenset({AnalyticsCapability.DETECT_ANOMALIES})

    def capabilities(self) -> frozenset[AnalyticsCapability]:
        return self._SUPPORTED

    async def detect_anomalies(
        self, account_id: str, *, window_days: int = 7,
    ) -> tuple[Anomaly, ...]:
        # Hand-authored heuristics over your platform's API live here.
        # MUST gate alerts by sample size — a single bad day is noise.
        return ()

    async def diagnose_performance(
        self, account_id: str, *, scope: PerformanceScope,
    ) -> PerformanceDiagnosis:
        raise NotImplementedError

    async def audit_creative(self, account_id: str) -> CreativeAudit:
        raise NotImplementedError

    async def analyze_budget_efficiency(
        self, account_id: str,
    ) -> BudgetEfficiency:
        raise NotImplementedError

Discovery + fault isolation

mureo.analytics.registry.discover_analytics_modules() iterates the mureo.analytics entry-point group, instantiates each class with no arguments, validates it against the Protocol, and registers it. Any exception during load, construction, attribute access, or Protocol validation is contained and reported as an AnalyticsModuleWarning — discovery never raises.

Built-ins register before discovery runs and therefore cannot be shadowed by a plugin. Two plugins claiming the same platform → first-discovered wins (second is dropped with a warning).

Skill consumption surface

Skills look up modules via the MCP tool mureo_analytics_modules_list, which returns one entry per registered platform with its advertised capabilities and source distribution. Skills must respect that result: if a platform is not in the list, or its module does not advertise the needed capability, skills emit analytics_not_available_for_<platform> instead of inventing heuristics.

Skills then run an advertised capability via the MCP tool mureo_analytics_run (Issue #440), passing platform, capability, and account_id (plus window_days for detect_anomalies or scope for diagnose_performance; detect_delivery_collapse takes neither — it uses its own multi-week history window, because a same-weekday baseline needs weeks of daily data, and 7 days would make it unusable). The dispatcher looks up your module, invokes the method credential-lazily, and returns status: ok with a JSON-serialized result — or a structured non-ok status the skill reports without failing:

  • no_analytics_module — no module registered for that platform.
  • capability_not_available — module registered but does not advertise it (includes an available_capabilities list).
  • error — your method raised (or returned something unserializable); carries error_type and detail. It is isolated here, never crashing the workflow.

This is the only path skills use to run your analysis. You do not — and must not — ship a separate MCP tool to expose it (see below): implement the AnalyticsModule methods and mureo_analytics_run drives them.

What you do NOT need to do

  • Register the module on the registry yourself — entry-points discovery does it on first use.
  • Reimplement anomaly thresholds — feel free to delegate to mureo.analysis.anomaly_detector (it is a pure function and ABI- stable; the built-in adapters do exactly this).
  • Ship a separate MCP tool — the registry surface is mureo's, not yours; you only ship the AnalyticsModule class.

15. Multi-tenant backend authoring

Status: store-capability family as of 0.10.43 (#196, #198, #207, #375, #411, #511). Audience: teams embedding mureo in a multi-tenant host — an agency backend, a SaaS control plane — that supplies its own credential storage instead of ~/.mureo/credentials.json.

The provider / skill / web-extension surfaces above extend what mureo can talk to. This section is about replacing where mureo keeps its own state: the RuntimeContext entry point swaps the file-backed default stores for yours, and a family of optional attributes on your SecretStore tells mureo how to behave in a deployment where one operator identity serves many client accounts.

Everything here is opt-in behind a single switch — the mureo.runtime_context_factory entry-point group. Every resolver in the family checks entry-point presence before consulting the store, so an installation with no factory registered keeps single-backend OSS behaviour byte-identically; none of the capabilities below is ever read.

The RuntimeContext entry point

mureo.core.runtime_context.RuntimeContext is a frozen dataclass bundling four pluggable backends plus a workspace identifier:

Field Protocol File-backed default
secret_store mureo.core.secret_store.SecretStore FilesystemSecretStore~/.mureo/credentials.json
state_store mureo.core.state_store.StateStore FilesystemStateStore — CWD-relative STATE.json / STRATEGY.md
knowledge_store mureo.core.knowledge_store.KnowledgeStore FilesystemKnowledgeStore — the /learn knowledge file
throttle_store mureo.core.throttle_store.ThrottleStore ProcessLocalThrottleStore
workspace_id str DEFAULT_WORKSPACE_ID ("default")

workspace_id is opaque to mureo — any non-empty, non-whitespace string works; an empty or whitespace-only value is rejected at construction time. Register a zero-arg callable returning a RuntimeContext under the entry-point group:

[project.entry-points."mureo.runtime_context_factory"]
acme = "mureo_acme_backend.context:build_runtime_context"

get_runtime_context() resolves once per process and caches:

  • 0 entry pointsdefault_runtime_context() (the file-backed defaults above).
  • 1 entry point → your factory is loaded and called; the result must be a RuntimeContext.
  • more than oneRuntimeContextFactoryError. Unlike providers and skills there is no first-wins: the context is a process singleton, and a packaging mistake should be loud, not silently resolved.

A factory that raises, or returns the wrong type, raises RuntimeContextFactoryError on every call — only a successfully constructed context is cached, so a broken factory stays visible until fixed rather than being masked by a silent fall-back to the defaults. reset_runtime_context() clears the cache; it is intended for tests.

How the store capabilities are read

Each capability is an optional attribute on your SecretStore. There is no extra Protocol to implement — declaring the attribute is the opt-in, and every resolver reads it defensively via getattr. The consequence cuts both ways:

  • A store shipped before a capability existed keeps loading unchanged.
  • A mistyped declaration is not an error. Each resolver validates the value's shape and, when it cannot trust it, collapses to that capability's default — for most of the family the permissive single-tenant behaviour, meaning your typo silently switches the feature off. The account allow-lists are the deliberate exception: on a multi-account backend an absent or unusable allow-list fails closed (see below). mureo emits no warning either way — test your declarations.

The SecretStore base contract itself is three methods: load(key) returns the stored dict, or {} for unknown keys (it must not raise on missing keys); save(key, value) overwrites; delete(key) is idempotent. Keys are platform names ("google_ads", "meta_ads", …).

Attribute Type Resolver (mureo.core.runtime_context) Consulted by
credentials_write_path Path runtime_credentials_path Configure-UI credential writers (#196)
multi_account_auth bool runtime_multi_account_auth mureo configure OAuth flow (#198)
ui_plugin_credential_fields Mapping[str, Collection[str]] runtime_ui_plugin_credential_fields Dashboard "Plugin credentials" section (#207)
search_console_sites Collection[str] runtime_search_console_sites Search Console MCP handlers (#375)
meta_account_ids Collection[str] runtime_meta_account_ids Meta Ads MCP handlers (#411)
google_ads_customer_ids Collection[str] runtime_google_ads_customer_ids Google Ads MCP handlers (#411)
amazon_token_saver Callable[[str, str | None], None] runtime_amazon_token_saver Amazon bridge token refresh (#511)

credentials_write_path: Path — where configure-UI writes land

The configure-UI credential write functions are path-based, while the MCP runtime reads through the pluggable store. Without this capability the two can split-brain (#194): a non-default backend reads from one place while the UI writes to another. runtime_credentials_path(default) resolves, in order:

  1. No factory registered → default, unchanged.
  2. Store declares credentials_write_path as a Path → that path.
  3. Store is the built-in FilesystemSecretStore → its path (back-compat; the concrete default store never declares the attribute).
  4. Otherwise → default.

Declare it when your store is filesystem-backed but not literally a FilesystemSecretStore — e.g. a composite that layers a per-tenant override file over a shared base — so the credential writers (#512 routes all of them through this resolver) target the file your reads actually honour. Omit it for a non-filesystem backend: a Path cannot represent Vault or a database, so the path-based write helpers stay on the host default — the honest ceiling of that API. A non-Path declaration is ignored (step 2 falls through).

multi_account_auth: bool — operator-shared OAuth, N clients

Declare multi_account_auth = True when the store's credentials are operator-shared across many client accounts — e.g. one Google developer_token + OAuth client, one Meta app, serving N clients whose customer_id / account_id arrive per-request out of band. The mureo configure OAuth flow then persists only the shared credentials and skips the per-account picker (#198).

The value is honoured only when it is exactly True"yes", 1, and a non-empty list all resolve to False, because a mistyped store must not silently suppress the picker. Note that this flag also flips the account allow-lists into fail-closed mode (below) — the two capabilities are designed as a pair.

ui_plugin_credential_fields — scoping the dashboard credential form

A per-provider allow-list of the credential-field keys the configure dashboard's "Plugin credentials" section should render, e.g. {"yahoo_ads": {"client_id", "client_secret", "refresh_token"}}. A multi-account backend uses it to surface only operator-shared auth fields and hide per-account ids that belong on its own per-client form — without it, a stale shared account_id saved in the dashboard can hijack every client's calls (the #202 incident that motivated #207).

Consumers treat the resolved mapping as: provider present → render only the listed keys (drop the card entirely when none remain); provider absent → keep all fields, so an unknown future plugin stays fully usable.

Normalization is strict but silent: a non-Mapping declaration resolves to None (no scoping); non-str provider keys are skipped; a value that is a str / bytes or not a Collection is skipped; if nothing survives, the result is None. A mistype must never hide fields the operator needs.

The account allow-lists — search_console_sites, meta_account_ids, google_ads_customer_ids

The Search Console / Meta Ads / Google Ads MCP tools take their account argument (site_url / account_id / customer_id) as a free caller argument, while a multi-account backend's shared OAuth can reach every client's properties — nothing else stops one client's workspace from querying a sibling's account. A multi-tenant backend closes the gap by declaring, for the active client, the allow-list of accounts each platform may touch; the platform handlers then enforce the effective id against it (#375, #411).

All three share one resolution contract:

  • No factory registeredNone (standalone OSS, unrestricted).
  • A usable declaration — any Collection[str] that is not itself a str / bytes → a frozenset of its non-blank string entries. Declaring the attribute opts into scoping, so an empty (or all-blank) collection is scoping ON with zero accounts — the handlers fail fast with "not configured for this client".
  • Absent or unusable, on a multi-account backend (multi_account_auth is True) → an empty frozenset, not None — fail closed. A shared-OAuth backend that reaches many clients must scope these platforms; a forgotten or mistyped allow-list must not silently reopen the cross-client leak. This is the one place in the family where a mistype blocks instead of reverting to permissive.
  • Absent or unusable, on a single-account backendNone (unrestricted — no shared-OAuth cross-client risk).

A bare str is "unusable", never a one-element allow-list — iterating it would produce per-character entries. Handler-side comparison is forgiving about formatting: Meta entries may be act_-prefixed or bare numeric (compared prefix-insensitively); Google Ads entries may carry hyphens or not (compared hyphen-insensitively).

amazon_token_saver — binding refreshed Amazon tokens to the tenant

The Amazon bridge mints and refreshes the short-lived LwA access token itself (one exchange per dispatch) and must persist the result, or the next call burns another exchange against a refresh token Amazon may already have rotated. Its default writer targets the runtime-resolved credentials.json (#512) — correct for a single-tenant install, wrong for multi-tenant, where that file is the operator-shared base whose reads strip the per-client token fields: refreshed tokens land where nothing reads them back.

Declare amazon_token_saver — a callable taking (access_token, refresh_token) where refresh_token may be None — and the bridge persists every refresh through it, binding the tokens to the active tenant's store (#511). Anything non-callable resolves to None and the bridge falls back to its default writer: a mistype must not break the refresh path.

The StateStore client registry — Reports across many clients

The capabilities above hang off your SecretStore. One more family hangs off your StateStore, and it is what turns the configure-UI's Reports tab from a single-workspace view into a client index. All three are optional and read defensively (getattr + callable), so a store that declares none of them keeps the OSS single-workspace behaviour exactly.

Method Returns Consulted by
list_clients() list[dict] — one row per selectable client mureo.web.report_clients.list_report_clients
state_store_for_client(slug) the StateStore to read for that client mureo.web.report_clients.state_store_for_client
set_client_archived(slug, archived) None mureo.web.report_clients.set_report_client_archived

Each list_clients() row is normalized to {slug, name, active, archived}:

  • slug (required, non-empty) identifies the client everywhere else; a row without one is dropped.
  • name defaults to the slug.
  • active and archived default to False, and a non-bool value is coerced rather than raising — a backend that writes "yes" or 1 still renders. list_clients() raising, or returning a non-list, is treated as "no seam at all": a backend bug must not blank out the picker.

archived — a decision about the client, not a view preference

An archived client is off the Reports index and no summary is fetched for it. That is the visible half; the half that matters is that your digest / sync process must stop collecting that client's figures while the flag is set. mureo cannot do that for you — it only records the decision through set_client_archived and renders the consequence honestly: the confirmation the operator sees says the figures are not collected while archived and that un-archiving does not backfill the gap. Do not re-interpret the flag as "hide from the list"; the wording in the UI would then be a lie.

Un-archiving is reachable from the same screen (a disclosure listing the archived clients), so an operator can always undo it without hand-editing your registry.

No set_client_archived, no archive control

GET /api/reports/clients advertises the capability as can_archive, and the dashboard renders the archive control only when it is true — not rendered-and-disabled. An OSS-only single-workspace install has no client registry to record the decision in, so it never shows the control at all and is completely unaffected by this feature. A mistyped declaration (a plain attribute rather than a method) reads as absent for the same reason every other capability does: the value must be callable.

POST /api/reports/clients/archive relays {slug, archived} to your seam. archived must be a real JSON boolean: a missing field or a non-bool is refused with 400 {"error": "archived_required"} and your seam is never called. It is validated rather than coerced because it decides whether a client's figures get collected at all — bool("false") is True, so coercion would archive a client whose caller meant the opposite, and a defaulted-to-False missing field would silently resume collection. Reject a non-bool on your side too; the two halves of this contract must agree about what archived: "false" means.

A seam that raises is logged server-side and answered with a plain 400 {"error": "archive_failed"} — your exception's message never reaches the browser. A blank slug is 400 {"error": "slug_required"}.

Card order on the index is deliberately not part of this seam. It is per-operator and browser-local (localStorage): purely visual, and two operators sharing one deployment reasonably want different orders, so it is never imposed through the backend.

Minimal example

# mureo_acme_backend/context.py
"""Multi-tenant RuntimeContext for the Acme host."""

from __future__ import annotations

from dataclasses import replace
from typing import Any

from mureo.core.runtime_context import RuntimeContext, default_runtime_context


class AcmeSecretStore:
    """Tenant-scoped SecretStore over the host's own storage."""

    # Operator-shared OAuth serving N clients (#198). Also flips the
    # allow-lists below into fail-closed mode.
    multi_account_auth = True

    # The dashboard renders only shared auth fields for this provider;
    # per-account ids live on the host's own per-client forms (#207).
    ui_plugin_credential_fields = {
        "google_ads": {
            "developer_token", "client_id", "client_secret", "refresh_token",
        },
    }

    def __init__(self, tenant: AcmeTenant) -> None:
        self._tenant = tenant

    # Account allow-lists for the ACTIVE tenant (#375 / #411).
    @property
    def google_ads_customer_ids(self) -> frozenset[str]:
        return self._tenant.google_ads_customer_ids

    @property
    def meta_account_ids(self) -> frozenset[str]:
        return self._tenant.meta_account_ids

    @property
    def search_console_sites(self) -> frozenset[str]:
        return self._tenant.search_console_sites

    # Amazon LwA refresh persistence, bound to the tenant (#511).
    def amazon_token_saver(
        self, access_token: str, refresh_token: str | None
    ) -> None:
        self._tenant.save_amazon_tokens(access_token, refresh_token)

    # --- SecretStore base contract -----------------------------------
    def load(self, key: str) -> dict[str, Any]:
        return dict(self._tenant.credentials.get(key, {}))

    def save(self, key: str, value: dict[str, Any]) -> None:
        self._tenant.store_credentials(key, dict(value))

    def delete(self, key: str) -> None:
        self._tenant.drop_credentials(key)  # idempotent


def build_runtime_context() -> RuntimeContext:
    tenant = resolve_active_tenant()  # however your host decides this
    return replace(
        default_runtime_context(),
        secret_store=AcmeSecretStore(tenant),
        workspace_id=tenant.id,
    )
[project.entry-points."mureo.runtime_context_factory"]
acme = "mureo_acme_backend.context:build_runtime_context"

Two things worth noting:

  • dataclasses.replace on the frozen context keeps the file-backed defaults for the state / knowledge / throttle stores while swapping only the pieces the host owns; construct RuntimeContext(...) directly to replace all four.
  • One process, one tenant. get_runtime_context() caches for the process lifetime, matching mureo's "one directory = one session" model — a host serving many tenants concurrently isolates them per process. reset_runtime_context() exists for tests, not for mid-run tenant switching.

Related documentation

  • ABI-stability.md — what is breaking, what is not, deprecation policy.
  • architecture.md — overall mureo architecture (provider layer + workflow commands + skills + MCP server).
  • authentication.md — how mureo loads credentials for built-in adapters (reference for plugin credential loading).
  • Built-in adapters in source — mureo/adapters/google_ads/adapter.py and mureo/adapters/meta_ads/adapter.py are reference implementations covering all four Protocols between them.