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.
- Introduction
- Quick start: a minimal plugin
- Provider Protocols
- Capabilities
- Models: frozen dataclasses and enums
- Skill matching
- Distribution patterns
- Entry-points registration
- Shipping skills with your plugin
- Security considerations
- End-to-end example
- Troubleshooting
- Web extensions
- Shipping analytics with your plugin
- Multi-tenant backend authoring
mureo's provider layer is split into three concentric ABIs:
- Capabilities — a
StrEnumof 13 stable identifiers (read_campaigns,write_budget, ...). Capabilities are the currency that skills declare they need and that providers declare they offer. - Protocols —
BaseProviderplus 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. - Registry — entry-points-based discovery
(
mureo.providersgroup). 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.providersentry-point group in itspyproject.toml.
That is enough to be discovered by mureo discover_providers() and
matched against the 16 built-in skills.
Phase 1 in scope:
- Add a new ad-platform provider implementing any subset of the four domain Protocols.
- Ship a directory of
SKILL.mdfiles via themureo.skillsentry-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
BaseProviderauthentication-free; each adapter decides its own credential loading. A futureAuthenticatedProviderProtocol will layer on top without breaking existing plugins. - Runtime authorization checks (
permit()). Today the matcher gates purely against the declaredcapabilitiesfrozenset. - 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
MCPToolProviderProtocol (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.
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.
[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_]*$).
"""ACME Ads provider for mureo."""
from mureo_acme_ads.adapter import AcmeAdsAdapter
__all__ = ["AcmeAdsAdapter"]"""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 ()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.
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.
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.
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. |
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.
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 berequired=Falseso 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_idreturned bymureo.google_ads.list_accessible_accountsfor child accounts reached via MCC traversal. Surfacing this in the description lets tooling auto-populate the field from a discovery call.
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 injectedSecretStore.
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.
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 name — must 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,retrievedorquote— or carrying aretrievedthat is not an ISO date — is refused withValueError. 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_prefixwas contributed by the provider named inplatform— 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 claiminggoogle_ads_renders nothing no matter whatplatformsays. - 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 withwarnings.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_rulesapplies 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_modelruns 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:Evidenceproves a source was named, not that the prose follows from it. APlatformModeltherefore 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.
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 Budgetstill wins — that is the agreed figure, and a configured sum is not an agreement. The sum comes back under its ownsource(platform_configured_sum, withis_platform_configuredset) 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 — anIncompletePlatform(platform, reason)record comes back inincomplete_platformsinstead, 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.
| 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).
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.
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.
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 Protocol — mureo.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_toolmust beasync. 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 thereadOnlyHintregistry 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 aPluginToolWarningand 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 likeupdate_campaignhas 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
inputSchemahonest — mureo enforces it server-side. Since #324 the dispatcher validates every call against your declaredinputSchemabefore it reaches your handler and rejects a violation withValueError. 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 bareKeyError/ValueErrorescape.
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.
Every plugin tool call is, automatically and without you opting in:
- Audited — appended (secret-masked) to
~/.mureo/plugin_audit.jsonl.okrecords whether the dispatch raised. If your handler reports a platform-side refusal without raising, return it as mureo's canonicalAPI error: ...text envelope: the record is then additionally markedplatform_ok: falsewith the reason, and the call is not promoted intoaction_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
readOnlyHintat 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'saction_log(platform="plugin:<your-dist>") only when aSTATE.jsonalready 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_getlike a built-in op. - Optional
_meta={"mureo": {...}}(the canonical alias; the intuitivemeta=spelling is also accepted):-
"reversal": {"operation": "...", "params": {...}}— recorded verbatim into the entry'sreversible_params. Since #324 this is executable, not audit-only:rollback_plan_getbuilds a real reversal whenoperationnames a registered, non-destructive tool — a built-in from mureo's rollback allow-list, or one of your own plugin tools. (Anoperationnaming 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 declaredinputSchemaproperty names. -
Runtime-correct reversal (
MCPReversibleToolProvider, #327/#328) — a staticreversalcannot know the entity id the platform will mint, nor the prior state you are about to overwrite. Implement the optionalcapture_reversalmethod 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. ReturnNoneto 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). Forrollback_applyto execute it,operationmust name a registered, non-destructive tool of the same plugin. A provider that does not implement it keeps its staticmeta["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 promotedaction_logentry'sobservation_dueis set from this so daily-check's evidence step reviews the outcome like a built-in write (nometrics_at_actionbaseline ⇒ 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 inaction_log.campaign_idandad_idare independently optional.entity_typeandentity_idmust 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 undeclaredad_idis treated as the canonical target, so a parentad_group_id/ad_set_idrequired 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 soSTRATEGY.md## Guardrailscaps are enforced on your platform too (#414). See the next section. -
"bid": {"bid_amount": "<key>", "cpc_bid": "<key>", "unit": "currency"|"micros"}— the bid twin ofbudget: declare where your tool carries its proposed bid so themax_bid_amount_per_ad_set/max_cpc_bid_per_ad_groupcaps are enforced on your platform too. See the bid-declarations section below.
-
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 owncurrent_daily_budgetconvention (in currency units), which is what the skills pass on every budget mutation — somax_daily_budget_increase_pctkeeps 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 declarecurrent: "current_daily_budget"alongsideunit: "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 spelledamountcannot false-trip a cap. The corollary: if your tool also carries a lifetime budget, declarelifetime— declaring onlydailyopts the tool out of the built-inlifetime_budget/total_amountscan 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 declaringdailynever silently switchesmax_daily_budget_increase_pctormax_total_daily_budgetoff. 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 — ornull/ 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.
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 bymax_bid_amount_per_ad_set, compared in account-currency minor units (direct, like Meta'sbid_amount).cpc_bid— the argument key carrying a bid capped bymax_cpc_bid_per_ad_group, compared in account-currency units (like Google'scpc_bid_microsafter ÷1e6). At least one ofbid_amount/cpc_bidis required.unit—"currency"(default) or"micros"(the value is divided by 1,000,000). The channel you name decides which cap constrains the bid;unitdecides its units — setmicroswhen your value is in micros so it lands in the cap's comparison unit, exactly as the built-incpc_bid_microspath 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_amountcannot 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 — ornull/ 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.
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. Declaringbodydoes 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_pctwants the campaign's current budget;max_total_daily_budgetwants 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.
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.
| 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,
}
)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 | 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. |
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.
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.
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 |
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 |
| 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). |
- 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 futureMoney(amount_minor: int, currency: str)abstraction may replace raw_microsfields in Phase 2; the change will be additive. - Dates:
datetime.date(day-grain in the account's timezone) on every reporting / scheduling field. Nointepoch seconds at the Protocol boundary.
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.
Not for platform semantics. Skills are matched on their
descriptionand 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 aPlatformModel(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.
---
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 ofrequired.
Any other top-level keys (e.g. metadata) are preserved in
SkillEntry.extra for forward compatibility.
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.
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])| 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.
mureo discovers providers via Python's standard entry-points mechanism, so any installation channel pip supports is automatically supported here. Four common patterns:
The simplest path. Publish your plugin to PyPI; users install with:
pip install mureo-acme-adsAfter installation, mureo picks up the mureo.providers entry point
automatically on the next discover_providers() call. No mureo
config change is required.
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-adsThe 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.
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.
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-adsThis is also the recommended pattern during development — pip install -e . gives you live re-loads without re-publishing.
| 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.
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).
[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.
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"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 pluginFor 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.
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.
[project.entry-points."mureo.skills"]
mureo-acme-ads = "mureo_acme_ads.skills:SKILLS_DIR"
[tool.hatch.build.targets.wheel]
packages = ["mureo_acme_ads"]"""Directory locator for mureo.skills entry-point."""
from pathlib import Path
SKILLS_DIR: Path = Path(__file__).resolve().parentmureo_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.mdfiles per entry-point root, - rejects files larger than 64 KiB,
- enforces UTF-8 strict decoding,
- uses
yaml.safe_loadonly (neveryaml.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.
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.
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 removal —
remove_native_skillsonly 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.
Your plugin runs inside the mureo process. The mureo project treats plugins as a known trust boundary but still expects basic hygiene:
- Never hardcode secrets. Load credentials from environment
variables, a credentials file under
~/.mureo/, or a system secret store. mureo itself loads~/.mureo/credentials.jsonfor built-in adapters; reuse that path or pick a clearly-namespaced alternative (~/.mureo/<plugin>.json). - 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_idfor the digits-only GAQL-safety pattern. - 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. - Bound external calls. Use timeouts on every HTTP call, bounded retries with jitter, and rate limits matching the platform's quota.
- Never trust third-party data as code. If your adapter
consumes user-supplied YAML/JSON/HTML, use
safe_load/ parameterized parsers — nevereval,exec, oryaml.load. - Log securely. Do not log access tokens, refresh tokens, developer tokens, or PII. Mask secrets even in debug logs.
- Per-plugin fault isolation. A broken
ep.load()or a malformed class is caught by a per-entry try/except and surfaced as aRegistryWarning. One bad plugin cannot break discovery of the others. - Class validation before registration. Bad
name/ badcapabilities/ wrong types are rejected with a clear error message embedding the class's__qualname__. - Deferred instantiation. mureo registers your class object,
not an instance. Your
__init__side effects do not run during discovery. - First-wins on duplicate names. A late-arriving malicious plugin cannot shadow an earlier legitimate one.
- Source-distribution tracking. Every
ProviderEntryrecords the PEP 503 normalized package name insource_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. - Strict-mode escape hatch. Setting
warnings.filterwarnings("error", category=RegistryWarning)(or the same forSkillDiscoveryWarning) turns the first malformed plugin into a startup failure — useful in CI and in security-conscious deployments.
- 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.
A complete plugin skeleton is reproduced below. It implements one domain Protocol, ships one skill, and is ready to publish.
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
[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"]"""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)---
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)"""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)- Confirm
pip show mureo-acme-adslists your package as installed. - Confirm the entry-points group is exactly
mureo.providers:python -c "from importlib.metadata import entry_points; print(list(entry_points(group='mureo.providers')))" - 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)
- Common causes:
namedoes not match^[a-z][a-z0-9_]*$,capabilitiesis a plainsetinstead offrozenset,display_nameis empty, top-level import in your module raises.
- 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))
- 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))
- The skill is
executableonly if the provider's set is a superset of the skill's required set.
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.
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.
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.
- 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 configurerather 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.
# 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));"
),
),
),
)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() unchangedLookup 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 "".
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() unchangedValidation discipline (mirrors display_name_i18n):
- Type-level problems are packaging bugs — a non-tuple
hidden_builtin_tabs, a non-strelement, or a non-boolreplaces_landingskips the whole extension at discovery with aWebExtensionWarning. - Value-level problems are soft — an unknown tab key is dropped
with a warning (the extension survives);
replaces_landing=Truewithout aview()is downgraded toFalsewith 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.
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:
groupmust be one ofBUILTIN_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 thehidden_builtin_tabsdiscipline, so plugins never couple to arbitrary internals of the app layout. An unknown group raises atDashboardCardconstruction and skips the extension. A card in a group a given mureo does not yet accept (e.g."reports"before this release) raisesValueError, 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 asStaticAssetscripts/styles, served fromGET /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, likeview(). A non-callable attribute, non-tuple return, or foreign element skips the whole extension with aWebExtensionWarning.- The index payload carries the cards as
dashboard_cards(always present,[]when none): one{group, html_fragment, scripts, styles}object per card.
[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 | 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).
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.
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.
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 viaMUREO_LOG_LEVEL=DEBUG mureo configure) surfacesWebExtensionWarningmessages explaining why an entry point was skipped (bad name, brokenep.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)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()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.
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.
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.
[project.entry-points."mureo.analytics"]
acme_ads = "mureo_acme_ads.analytics:AcmeAdsAnalyticsModule"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.
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.
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_ads → plugin: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 #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.
# 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 NotImplementedErrormureo.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).
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 anavailable_capabilitieslist).error— your method raised (or returned something unserializable); carrieserror_typeanddetail. 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.
- 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
AnalyticsModuleclass.
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.
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 points →
default_runtime_context()(the file-backed defaults above). - 1 entry point → your factory is loaded and called; the result
must be a
RuntimeContext. - more than one →
RuntimeContextFactoryError. 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.
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) |
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:
- No factory registered →
default, unchanged. - Store declares
credentials_write_pathas aPath→ that path. - Store is the built-in
FilesystemSecretStore→ itspath(back-compat; the concrete default store never declares the attribute). - 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).
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.
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 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 registered →
None(standalone OSS, unrestricted). - A usable declaration — any
Collection[str]that is not itself astr/bytes→ afrozensetof 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 emptyfrozenset, notNone— 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 backend →
None(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).
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 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.namedefaults to the slug.activeandarchiveddefault toFalse, and a non-bool value is coerced rather than raising — a backend that writes"yes"or1still 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.
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.
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.
# 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.replaceon the frozen context keeps the file-backed defaults for the state / knowledge / throttle stores while swapping only the pieces the host owns; constructRuntimeContext(...)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.
- 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.pyandmureo/adapters/meta_ads/adapter.pyare reference implementations covering all four Protocols between them.