Skip to content

Latest commit

 

History

History
573 lines (528 loc) · 46.8 KB

AGENTS.md

File metadata and controls

573 lines (528 loc) · 46.8 KB

AGENTS.md

Guidelines for AI agents contributing to the mureo codebase.

Project Overview

mureo — your local-first AI ad ops crew. Find waste, audit changes, run ad accounts safely.

Works with Claude Code, Cursor, Codex & Gemini. mureo sits on top of the official ad-platform MCPs and gives your AI a strategy to follow, an outcome to be measured against, and an audit trail you can show to anyone — credentials never leave your machine.

mureo combines strategy context, workflow commands, and domain knowledge to help AI agents achieve marketing goals across platforms. Provides MCP tools for direct platform operations and workflow commands for strategy-driven ad operations via Claude Code slash commands. Designed for AI agents — no database, no LLM SDK, no web framework.

Build & Test

pip install -e ".[dev]"
pytest tests/ -v
pytest tests/ --cov=mureo --cov-report=term-missing

# Browser assets (only when mureo/_data/web/ changes). Node's built-in
# runner — no package.json, no dependencies, no build step.
node --test tests/js/*.test.js

Architecture

mureo/
├── google_ads/          # Google Ads API client (Mixin composition)
│   ├── client.py        # GoogleAdsApiClient (main entry)
│   ├── mappers.py       # Response mapping to structured dicts
│   ├── _enum_names.py   # map_enum_name + SDK-derived int->name maps (raw protobuf has no .name, #588)
│   ├── _placement_mappers.py # Negative-placement + group_placement_view row mappers (#544/#547)
│   ├── _ads.py          # AdsMixin (RSA create/update/status/list)
│   ├── _ads_display.py  # DisplayAdsMixin (RDA create + RDAUploadError)
│   ├── _asset_groups.py # AssetGroupsMixin (Performance Max asset groups: one read for
│   │                    #   text + images, and the text swap via one atomic
│   │                    #   GoogleAdsService.mutate, #590/#626). Owns both field-type
│   │                    #   tables — text width limits and image dimension rules
│   ├── _asset_groups_images.py # AssetGroupImagesMixin (the P-MAX image swap, #626:
│   │                    #   existing asset id or local upload, one atomic mutate)
│   ├── _keywords.py     # KeywordsMixin (add/remove/suggest/diagnose)
│   ├── _placements.py   # PlacementsMixin (negative placements: sites/apps/app categories, #544)
│   ├── _analysis.py     # AnalysisMixin aggregator, composing the split modules below
│   ├── _analysis_auction.py / _analysis_btob.py / _analysis_budget.py    # + _analysis_constants.py
│   ├── _analysis_keywords.py / _analysis_performance.py / _analysis_rsa.py / _analysis_search_terms.py
│   ├── _diagnostics.py  # DiagnosticsMixin (campaign diagnosis)
│   ├── _extensions.py   # ExtensionsMixin aggregator, composing the split modules below
│   ├── _extensions_callouts.py / _extensions_conversions.py / _extensions_sitelinks.py / _extensions_targeting.py
│   ├── _monitoring.py   # MonitoringMixin (anomaly detection/reporting)
│   ├── _creative.py     # CreativeMixin (LP analysis/message match)
│   ├── _media.py        # MediaMixin (image asset upload)
│   ├── _rsa_validator.py     # RSA ad text validator
│   ├── _rda_validator.py     # RDA input validator (display ads)
│   ├── _message_match.py     # LP screenshot capture (Playwright, SSRF-guarded)
│   ├── _gaql_validator.py    # GAQL input validators (ASCII-only ID/date whitelists — see below)
│   ├── _date_ranges.py       # Period-over-period comparison windows (SDK-free, #809)
│   └── accounts.py           # Accessible-customer / account listing
├── meta_ads/            # Meta Ads API client (Mixin composition)
│   ├── client.py        # MetaAdsApiClient (main entry)
│   ├── mappers.py       # Response mapping
│   ├── _campaigns.py    # CampaignsMixin
│   ├── _ad_sets.py      # AdSetsMixin
│   ├── _ads.py          # AdsMixin
│   ├── _creatives.py    # CreativesMixin (image/carousel/collection/dynamic)
│   ├── _videos.py       # VideosMixin (AdVideo upload/status/thumbnails)
│   ├── _audiences.py    # AudiencesMixin
│   ├── _pixels.py       # PixelsMixin
│   ├── _placement_exclusions.py  # PlacementExclusionsMixin (ad-set publisher exclusions, #544)
│   ├── _insights.py     # InsightsMixin
│   ├── _analysis.py     # AnalysisMixin
│   ├── _catalog.py      # CatalogMixin (product catalogs/feeds)
│   ├── _conversions.py  # ConversionsMixin (Conversions API / CAPI)
│   ├── _hash_utils.py   # SHA-256 PII hashing for CAPI
│   ├── _leads.py        # LeadsMixin (lead forms/leads)
│   ├── _page_posts.py   # PagePostsMixin (page posts/boost, page photos for an Instant Form cover)
│   ├── _instagram.py    # InstagramMixin (accounts/media/boost)
│   ├── _split_test.py   # SplitTestMixin (A/B split tests)
│   ├── _ad_rules.py     # AdRulesMixin (automated rules)
│   ├── _conversion_count.py  # Conversion-count parsing helper
│   ├── _period.py       # Date-period resolution helper
│   └── accounts.py      # Ad-account listing
├── search_console/      # Google Search Console API client (reuses Google OAuth2 credentials)
│   └── client.py        # SearchConsoleApiClient
├── mcp/                 # MCP server (Google/Meta/Search Console + Rollback/Analysis + Analytics registry, Creative Studio, Learning, mureo Context)
│   ├── server.py                          # MCP Server entry point (stdio-based)
│   ├── _helpers.py                        # Shared handler utilities
│   ├── journal.py                         # Append-only JOURNAL.jsonl of every tool call + outcome (#758)
│   ├── journal_chain.py                   # The append itself (#758 phase 6): rotation, the `prev`/`h`
│   │                                      #   hash chain across files, and verify_chain. A truncated
│   │                                      #   TAIL is the one thing it cannot prove — say so, always
│   ├── _journal_hook.py                   # Dispatcher hook: family/mutating classification, one record per call (#758)
│   ├── tools_google_ads.py                # Google Ads tool definitions (aggregator)
│   ├── _tools_google_ads_*.py             # Tool definition sub-modules
│   ├── _handlers_google_ads.py            # Google Ads base handlers
│   ├── _google_ads_client.py              # Google Ads client / credential resolution
│   ├── _handlers_google_ads_extensions.py # Extensions handlers
│   ├── _handlers_google_ads_analysis.py   # Analysis handlers
│   ├── tools_meta_ads.py                  # Meta Ads tool definitions (aggregator)
│   ├── _tools_meta_ads_*.py               # Tool definition sub-modules
│   ├── _handlers_meta_ads.py              # Meta Ads base handlers
│   ├── _meta_ads_client.py                # Meta Ads client / credential resolution
│   ├── _handlers_meta_ads_extended.py     # Extended handlers
│   ├── _handlers_meta_ads_other.py        # Other handlers
│   ├── _handlers_meta_ads_creatives.py    # Upload / creative handlers
│   ├── tools_search_console.py            # Search Console tool definitions
│   ├── _handlers_search_console.py        # Search Console handlers
│   ├── tools_rollback.py                  # rollback_plan_get / rollback_apply
│   ├── _handlers_rollback.py              # Rollback handlers (lazy-resolve dispatcher)
│   ├── tools_batch.py                     # mureo_batch_begin / _end / _status (#549)
│   ├── _handlers_batch.py                 # Batch lifecycle handlers
│   ├── tools_change_import.py             # mureo_external_changes_import (#545)
│   ├── _handlers_change_import.py         # Change-import handler
│   ├── tools_analysis.py                  # analysis_anomalies_check / analysis_delivery_collapse_* / analysis_exclusion_impact_preview
│   ├── _handlers_analysis.py              # Anomaly detector composition handler
│   ├── _handlers_delivery_collapse.py     # Delivery-collapse check / diagnose handlers (#546)
│   ├── _handlers_exclusion_impact.py      # analysis_exclusion_impact_preview handler (#547)
│   ├── exclusion_preflight.py             # Pre-dispatch exclusion sizing + refusal + notice (#547)
│   ├── exclusion_sources.py               # Built-in per-platform delivery sources for the above (#547)
│   ├── tools_analytics_registry.py        # mureo_analytics_modules_list / mureo_analytics_run (#440)
│   ├── tools_creative_studio.py           # creative_studio_* (visual generation + compose)
│   ├── tools_learning.py                  # mureo_learning_insights_get / mureo_consult_advisor
│   ├── tools_learning_preflight.py        # mureo_learning_reset_preflight (#548)
│   ├── tools_mureo_context.py             # STRATEGY.md / STATE.json + mureo_outcome_evaluate tools
│   ├── _handlers_mureo_context.py         # Context (STRATEGY/STATE) handlers
│   ├── _tools_decisions.py                # mureo_decision_record (#758 phase 3) — a PARTIAL of the
│   │                                      #   mureo_context family, not a family of its own, so
│   │                                      #   server.py and the journal labels are untouched
│   ├── _auto_evaluation_hook.py           # mureo_state_get's automatic observation closure
│   │                                      #   (#758 phase 5): the `auto_evaluate` switch, the
│   │                                      #   policy-gate check that decides whether the write
│   │                                      #   may happen, and the response fragment
│   ├── _tools_settings.py                 # mureo_state_settings_set (#848) — workspace settings,
│   │                                      #   another PARTIAL of the mureo_context family
│   ├── _ops_history_param.py              # `expected` / `decision_id` on mutating tools (#848):
│   │                                      #   schema injection, the requirement check, the binding
│   ├── _ops_history_records.py            # The same requirement on the two recording tools
│   ├── _history_ops.py                    # The `decisions` / `ops_history` history sources
│   ├── _handlers_decisions.py             # Its handler: mints the id + timestamp, appends
│   ├── _tools_history.py                  # mureo_history_query (#758 phase 4b) — another PARTIAL
│   │                                      #   of the mureo_context family; READ-ONLY, so the
│   │                                      #   dispatcher injects no `reason` into its schema
│   ├── _handlers_history.py               # Its handler: the filters, and one bounded section per
│   │                                      #   source (action_log / journal / daily / reports /
│   │                                      #   decisions / ops_history)
│   ├── journal_read.py                    # Reading JOURNAL.jsonl back — the parse, the date, the
│   │                                      #   filter and the tail reader, shared with `mureo journal`.
│   │                                      #   The *_files forms walk the rotated siblings as one record
│   ├── _client_factory.py                 # Per-platform BYOD-vs-live client factory
│   └── tool_provider.py                   # Third-party plugin → MCP tool exposure layer (#89)
├── cli/                 # Typer CLI (setup + auth + configure + BYOD + providers + rollback + repair + journal; ad ops are via MCP)
│   ├── main.py          # CLI entry point (`mureo` command)
│   ├── setup_cmd.py     # `mureo setup claude-code` / `cursor` / `codex` / `gemini`
│   ├── setup_codex.py   # Codex install-kit: MCP, credential guard, operational + foundation skills
│   ├── setup_gemini.py  # Gemini extension manifest at ~/.gemini/extensions/mureo/
│   ├── native_skills.py # Deploy/remove plugin native slash skills via `mureo.native_skills` group (#439)
│   ├── configure_cmd.py # `mureo configure` — open the local web configuration UI
│   ├── byod_cmd.py      # `mureo byod import` / `status` / `remove` / `clear`
│   ├── providers_cmd.py # `mureo providers list` / `install` / `uninstall` (official MCP catalog)
│   ├── service_cmd.py   # `mureo service` — install/restart the auto-start configure daemon (#241)
│   ├── upgrade_cmd.py   # `mureo upgrade` — pipx venv-aware bulk upgrade of mureo + plugins
│   ├── auth_cmd.py      # `mureo auth setup` / `status` / `check-*` / `upgrade-google`
│   ├── rollback_cmd.py  # `mureo rollback list` / `show` (inspection only; apply routes through MCP)
│   ├── journal_cmd.py   # `mureo journal` — read back JOURNAL.jsonl (last/tool/since/failures/mutations/json/all)
│   ├── _journal_verify.py # `mureo journal --verify` — the hash chain over the live file and its
│   │                    #   rotated siblings; exit 0 ok / 1 broken / 2 no journal (#758 phase 6)
│   ├── repair_cmd.py    # `mureo repair platform-key` — drop a platforms entry the DOCUMENT
│   │                    #   shows to be wrong (duplicate of a resolvable key, or empty stub);
│   │                    #   dry run by default, backs up first (#610/#616).
│   │                    #   `--all` sweeps every client, summary first, one prompt (#614).
│   │                    #   `--key <k> --drop-duplicate` records the operator's OWN decision
│   │                    #   about a duplicate whose two keys both resolve (#636); not with --all
│   ├── _repair_preview.py # What that command prints — the half that has to be TRUE: why an
│   │                    #   entry can go, what is NOT changed (scoped to the plan), every
│   │                    #   same-account sibling, and conversion_action_types (#616/#617/#618).
│   │                    #   Also the not_collected note an entry carries (#643) and both
│   │                    #   entries of a duplicate mureo hands back to decide (#645)
│   ├── _repair_clients.py # Which STATE.json files `--all` sweeps — reuses the Reports tab's
│   │                    #   optional list_clients / state_store_for_client seam, never a second one
│   ├── _state_file.py   # The shared `--state-file` option + workspace default (rollback + repair)
│   ├── _tty.py          # TTY-safe helpers for non-interactive setup + terminal_safe() scrubbing
│   └── web_auth.py      # Browser-based OAuth wizard spawned by `mureo configure` (per-platform creds)
├── context/             # File-based strategy context (no DB)
│   ├── strategy.py      # STRATEGY.md parser/writer
│   ├── state.py         # STATE.json parser/writer
│   ├── models.py        # StrategyEntry, StateDocument, CampaignSnapshot, ActionLogEntry (rollback_of, batch_id), BatchRecord
│   ├── batch.py         # Batch id minting + the action_log stamping rule (#549)
│   ├── decisions.py     # The `decisions` section (#758 phase 3): DecisionRecord, its
│   │                    #   bounds, append_decision and the pure queries. Append-only
│   │                    #   — an adoption is a NEW record naming the first in
│   │                    #   `supersedes`, never an edit — because `display.proposals`
│   │                    #   is replaced whole on every dashboard write and so cannot
│   │                    #   be a history
│   ├── _decision_codec.py # That section's two codec halves. Strict like action_log's,
│   │                    #   not tolerant like batches': a decision record IS history
│   ├── expected.py, settings.py, decision_chain.py, ops_intent.py,
│   │   ops_linkage.py, ops_fields.py, ops_history_view.py,
│   │   ops_history_query.py
│   │                    #   Ops history (#848): expected effects, the settings section,
│   │                    #   linking entries to decisions inside the locked append, the
│   │                    #   derived decision → actions → evaluation view, and the
│   │                    #   date-bounded read both MCP and the dashboard use. See
│   │                    #   docs/ops-history.md
│   ├── daily.py         # The day-grain history write MINUS the file (#690/#710):
│   │                    #   `with_platform_daily(doc, ...)` (guard + per-date merge +
│   │                    #   trim) and `capped_platform_daily(daily)` (the 35-day
│   │                    #   retention rule on its own), for a writer that lands
│   │                    #   `daily` inside its OWN atomic document write.
│   │                    #   `set_platform_daily` is the path-based wrapper, so both
│   │                    #   routes keep one set of semantics. Optional `as_of_date`
│   │                    #   = today in the ACCOUNT's timezone: an account closes its
│   │                    #   day locally, so the host clock refused a complete
│   │                    #   yesterday-JST on a UTC host every night
│   ├── observations.py  # What closes an action_log observation, and which are
│   │                    #   past due (#651). ONE rule for `mureo_state_get(
│   │                    #   action_log="pending")` and the Reports triage count;
│   │                    #   two entry shapes (rendered dict / ActionLogEntry)
│   ├── auto_evaluation.py # Closing a past-due observation mureo can decide on
│   │                    #   its own (#758 phase 5): the rule order, the fixed
│   │                    #   skip vocabulary, and ONE locked write for the whole
│   │                    #   pass. Pure decision + write, no MCP imports
│   ├── actor_stamp.py   # `stamp_actor` — the WHO/WHY stamp shared by
│   │                    #   `append_action_log` and the automatic closure, so a
│   │                    #   mureo-written entry carries the same identity on both
│   ├── monthly_budget.py # The `## Custom: Monthly Budget` reader (#652) — the operator's
│   │                     #   INTENDED monthly spend, twin of `guardrails_from_strategy_text`.
│   │                     #   Separate type from `Guardrails` on purpose: a target is not a
│   │                     #   ceiling. "Not set" is a first-class answer (never 0), a total
│   │                     #   derived from `max_total_daily_budget` says so, and the figure
│   │                     #   stays in STRATEGY.md — never copied into STATE.json
│   ├── platform_monthly_budget.py # Rung 2 of that precedence (#656): the sum of the
│   │                     #   per-campaign monthly budgets, for platforms that DECLARED they
│   │                     #   have the concept (`register_monthly_budget_support`; core
│   │                     #   declares none). A third `source`, so a configured sum is never
│   │                     #   shown as an agreed target. No total is stored — it is computed
│   │                     #   on read — and an incomplete or uncollected campaign set is
│   │                     #   withheld rather than summed short, reported as
│   │                     #   `IncompletePlatform(platform, reason)` so an operator is told
│   │                     #   WHICH gap it is (a wrong declaration, a sync behind, a failed
│   │                     #   collection) instead of reading a debug log. The two splits are
│   │                     #   separate fields — `per_platform` is only ever what the operator
│   │                     #   wrote — and the type refuses a split that does not match `source`
│   ├── platform_guards.py # Write-time platform-key guards (#534/#609) — the one "is this key
│   │                      #   real?" answer every other surface asks, including the read-side
│   │                      #   label path (`installed_platform_names`, shared since #631)
│   ├── platform_repair.py # The repair half (#610): plan + drop an entry under an unresolvable
│   │                      #   key. Unresolvable is the FILTER, not the criterion — the document
│   │                      #   must show the entry wrong (#616), and conversion_action_types is
│   │                      #   never dropped (#617). `drop_duplicates` is the one widening: a key
│   │                      #   the OPERATOR named, dropped only against a same-account sibling
│   │                      #   (#636). Drops, never merges; backs up; no action_log
│   ├── action_log_guard.py # A whole-document write that shortens or rewrites action_log leaves a
│   │                      #   timestamped backup and a WARNING first (#758 phase 6). Advisory like
│   │                      #   warn_on_duplicate_accounts — but a backup that FAILS stops the write
│   └── errors.py        # Context-specific errors
├── analysis/            # Analysis utilities
│   ├── lp_analyzer.py   # Landing page analyzer
│   ├── anomaly_detector.py  # Zero-spend / CPA-spike / CTR-drop detection (pure, sample-size-gated)
│   ├── delivery_collapse.py # Delivery-collapse detection (#546) — weekday-aware baseline taken
│   │                        #   from the platform's own daily delivery, never from action_log
│   ├── delivery_collapse_config.py  # ## Guardrails -> CollapseThresholds (the only I/O half)
│   ├── collapse_diagnosis.py        # Change x metric timeline + elimination ladder; reports the
│   │                                #   open questions, never a cause it did not evidence
│   ├── exclusion_impact/    # Delivery-impact preview for bulk exclusions (#547; pure)
│   │   ├── models.py        # DeliveryRecord / ExclusionTarget / ExclusionImpact + coverage verdicts
│   │   ├── matching.py      # Per-entity-kind match rules (host, app id, negative keyword match type)
│   │   ├── estimator.py     # The share itself — incremental and cumulative
│   │   ├── rules.py         # ## Guardrails exclusion keys, the one refusal decision, inert-rule reporting
│   │   └── surfaces.py      # Which tools are exclusion surfaces (mureo's + plugin-registered)
│   └── tracking/        # Tracking-parameter consistency: platform-neutral detector + per-platform URL accessors (#550)
├── rollback/            # Rollback feature (allow-list gated, append-only audit trail)
│   ├── models.py        # RollbackStatus / RollbackPlan + batch verdicts (BatchCoverage, BatchRollbackPlan)
│   ├── planner.py       # plan_rollback(ActionLogEntry) -> RollbackPlan | None
│   ├── batch.py         # plan_batch_rollback(doc, batch_id) -> every member, gaps included (#549)
│   └── executor.py      # execute_rollback(...) -> appends ActionLogEntry(rollback_of=index)
├── adapters/            # Provider adapters wrapping each ad-platform client as a registry Protocol
├── analytics/           # Analytics-module registry for external MCP / plugin platforms (#120)
├── change_import/       # Import changes made OUTSIDE mureo into action_log (#545)
│   ├── models.py        # ExternalChange / ChangeFeedResult / ChangeImportOutcome
│   ├── protocol.py      # ChangeFeedProvider Protocol (`mureo.change_feeds` entry-point group)
│   ├── registry.py      # Discovery + per-plugin fault isolation + lookup
│   ├── dedupe.py        # Already-imported vs mureo's own change (why one is approximate)
│   ├── importer.py      # Watermark, action_log write, per-platform outcome
│   └── builtin/         # Native feeds — google_ads only today; see docs/change-import.md
├── core/                # Extension Protocols + file-backed impls + RuntimeContext; provider & skill discovery
│   └── rotation.py      # Size-bounded append files (#758 phase 6): the one MUREO_JOURNAL_MAX_BYTES
│                        #   reader, the rotated name, and sibling_files (the whole record, oldest first)
├── providers/           # Official MCP provider catalog + one-command install helpers (#86)
├── policy/              # Built-in policy gates (strategy_gate) — ship with OSS, run by default
│   ├── learning_rules.py     # Per-platform learning-period facts + their first-party sources (#548)
│   ├── platform_model.py     # Per-platform delivery-model prose + first-party sources (#648) —
│   │                         #   the ONLY always-on plugin channel: rendered into the MCP
│   │                         #   server's `instructions`, scoped to the platforms served
│   └── learning_reset.py     # Reset-class + learning-state pre-flight the gate refuses on
├── learning/            # Read-side /learn companion: insight federation across configured sources
├── creative_studio/     # Creator-grade ad-creative (image) generation via pluggable providers
├── byod/                # Bring Your Own Data — CSV-backed offline analysis (see BYOD Mode below)
├── web/                 # Local `mureo configure` UI — stdlib http.server on 127.0.0.1 (no web framework)
├── demo/                # `mureo demo init` synthetic-bundle bootstrap (round-trips through BYOD)
├── auth.py              # Credentials management (~/.mureo/credentials.json + env vars + Meta token auto-refresh)
├── auth_setup.py        # Interactive setup wizard (browser OAuth flow)
├── credential_guard.py  # Blocks AI agents from reading ~/.mureo/credentials.json
├── _credential_guard/   # The hook payloads' pieces, one module per step (reasons,
│                        #   path_guard, chars, quoting, spans, braces, bash_guard);
│                        #   import from credential_guard.py
├── logging_setup.py     # Configure-server logging (#581): rotating ~/.mureo/logs/configure.log,
│                        #   MUREO_LOG_LEVEL, and the HTTP access-log query scrubber. Installed by
│                        #   the configure entry point only — never at import time, never on root.
└── throttle.py          # Rate limiting (token bucket + rolling hourly cap)

skills/                       # Native slash skills — one `<name>/SKILL.md` per skill (migrated from
│                             #   .claude/commands in #439; invocable as `/<name>`). Plugins contribute
│                             #   more via the `mureo.native_skills` entry-point group, deployed/removed
│                             #   by `mureo/cli/native_skills.py`.
├── onboard/, daily-check/, rescue/, budget-rebalance/, …   # ~20 operational (workflow) skills
│   └── SKILL.md
└── _mureo-*/                 # 7 foundation skills: _mureo-shared / _mureo-strategy / _mureo-google-ads /
    └── SKILL.md              #   _mureo-meta-ads / _mureo-amazon-ads / _mureo-learning / _mureo-pro-diagnosis
docs/integrations.md          # Platform discovery + external MCP integration guide

The skill tree ships twice. skills/ is canonical; mureo/_data/skills/ is the copy the wheel installs. Edit both, always — every skill present in either tree is byte-compared, whole directory against whole directory, by tests/test_plugin_manifests.py::test_skill_copies_are_byte_identical. The one exemption is _mureo-pro-diagnosis (canonical only, because /learn scaffolds the operator's own copy under ~/.claude/skills/); it is declared in _CANONICAL_ONLY_SKILLS with its reason, not hidden behind a name filter.

MCP Tools

Google Ads

Category Tools
Campaigns campaigns.list, campaigns.get, campaigns.create, campaigns.update, campaigns.update_status, campaigns.diagnose
Ad Groups ad_groups.list, ad_groups.create, ad_groups.update
Ads ads.list, ads.create, ads.create_display, ads.update, ads.update_status, ads.policy_details
Keywords keywords.list, keywords.add, keywords.remove, keywords.suggest, keywords.diagnose, keywords.pause, keywords.audit, keywords.cross_adgroup_duplicates
Negative Keywords negative_keywords.list, negative_keywords.add, negative_keywords.remove, negative_keywords.add_to_ad_group, negative_keywords.suggest
Negative Placements negative_placements.list, negative_placements.add, negative_placements.remove
Placement Performance (read used by analysis_exclusion_impact_preview: group_placement_view via get_placement_performance)
Budget budget.get, budget.update, budget.create
Accounts accounts.list
Search Terms search_terms.report, search_terms.analyze
Sitelinks sitelinks.list, sitelinks.create, sitelinks.remove
Callouts callouts.list, callouts.create, callouts.remove
Conversions conversions.list, conversions.get, conversions.performance, conversions.create, conversions.update, conversions.remove, conversions.tag
Targeting recommendations.list, recommendations.apply, device_targeting.get, device_targeting.set, bid_adjustments.get, bid_adjustments.update, location_targeting.list, location_targeting.update, schedule_targeting.list, schedule_targeting.update, change_history.list, demographic_targeting.list, audience_targeting.list
Analysis performance.report, performance.analyze, cost_increase.investigate, health_check.all, ad_performance.compare, ad_performance.report, network_performance.report, budget.efficiency, budget.reallocation, auction_insights.get, rsa_assets.analyze, rsa_assets.audit, search_terms.review
B2B btob.optimizations
Creative landing_page.analyze, creative.research
Monitoring monitoring.delivery_goal, monitoring.cpa_goal, monitoring.cv_goal, monitoring.zero_conversions
Capture capture.screenshot
Device device.analyze
CPC cpc.detect_trend
Assets assets.upload_image, image_assets.list
Performance Max (#590, #626) asset_group_assets.list (text + images in one read), asset_group_assets.replace (text), asset_group_images.replace (images, from an asset id or a local file) — P-MAX creative lives on asset_group_asset, not ad_group_ad

Meta Ads

Category Tools
Campaigns campaigns.list, campaigns.get, campaigns.create, campaigns.update, campaigns.pause, campaigns.enable
Ad Sets ad_sets.list, ad_sets.create, ad_sets.update, ad_sets.get, ad_sets.pause, ad_sets.enable
Ads ads.list, ads.create, ads.update, ads.get, ads.pause, ads.enable
Creatives creatives.create_carousel, creatives.create_collection, creatives.list, creatives.create, creatives.create_dynamic, creatives.upload_image
Images images.upload_file
Insights insights.report, insights.breakdown
Audiences audiences.list, audiences.create, audiences.get, audiences.delete, audiences.create_lookalike
Targeting targeting.search, targeting.categories
Conversions API conversions.send, conversions.send_purchase, conversions.send_lead
Pixels pixels.list, pixels.get, pixels.stats, pixels.events, pixels.create
Analysis analysis.performance, analysis.audience, analysis.placements, analysis.cost, analysis.compare_ads, analysis.suggest_creative
Placement Exclusions excluded_placements.get, excluded_placements.set
Product Catalog catalogs.list, catalogs.create, catalogs.get, catalogs.delete, products.list, products.add, products.get, products.update, products.delete, feeds.list, feeds.create
Lead Ads lead_forms.list, lead_forms.get, lead_forms.create, leads.get, leads.get_by_ad
Videos videos.upload, videos.upload_file
Split Tests split_tests.list, split_tests.get, split_tests.create, split_tests.end
Ad Rules ad_rules.list, ad_rules.get, ad_rules.create, ad_rules.update, ad_rules.delete
Pages pages.list
Page Posts page_posts.list, page_posts.boost
Instagram instagram.accounts, instagram.media, instagram.boost

Search Console

Category Tools
Sites sites.list, sites.get
Analytics analytics.query, analytics.top_queries, analytics.top_pages, analytics.device_breakdown, analytics.compare_periods
Sitemaps sitemaps.list, sitemaps.submit
URL Inspection url_inspection.inspect

mureo Core Tools (platform-independent)

These families are not tied to a single ad platform. Tool names are the exact MCP tool ids.

Family Tools
Analytics Registry (#440) mureo_analytics_modules_list, mureo_analytics_run
Rollback rollback_plan_get, rollback_apply
Batch (#549) mureo_batch_begin, mureo_batch_end, mureo_batch_status
Change Import (#545) mureo_external_changes_import
Analysis analysis_anomalies_check, analysis_delivery_collapse_check, analysis_delivery_collapse_diagnose, analysis_exclusion_impact_preview
Creative Studio creative_studio_providers_list, creative_studio_generate_visual, creative_studio_edit_visual, creative_studio_compose, creative_studio_brand_kit_get
Learning mureo_learning_insights_get, mureo_consult_advisor
Learning pre-flight (#548) mureo_learning_reset_preflight
mureo Context mureo_strategy_get, mureo_strategy_set, mureo_state_get, mureo_state_action_log_append, mureo_state_upsert_campaign, mureo_state_report_set, mureo_state_display_set, mureo_state_platform_metrics_set, mureo_state_platform_daily_set, mureo_state_platform_not_collected_set, mureo_state_workspace_not_collected_set, mureo_state_set_conversion_events, mureo_outcome_evaluate, mureo_decision_record, mureo_history_query, mureo_state_settings_set

Design Constraints

  • Strategy-driven — all operations are guided by STRATEGY.md context.
  • Workflow-first — slash commands orchestrate multi-step operations.
  • Platform-agnostic — designed to work with official platform MCPs as they become available.
  • No database dependencies — no SQLAlchemy, no ORM. File-based context only (STRATEGY.md / STATE.json).
  • No LLM dependencies — no OpenAI SDK, no Anthropic SDK. Tools return structured data for agents to interpret.
  • No web framework dependencies — no FastAPI, no Flask. CLI (Typer) and MCP (stdio) only.
  • Tools return structured JSON data only — no formatted text, no Markdown in tool responses.
  • All data models use frozen dataclasses — immutable by default.
  • Credentials via file or env vars — ~/.mureo/credentials.json with environment variable fallback.
  • Built-in rate limiting — token bucket throttling per platform prevents API bans from high-speed agent requests (Google Ads: 10 QPS, Meta Ads: 20 QPS + 50K/hr cap, Search Console: 5 QPS, Creative Studio: 1 QPS + 120/hr cap, Plugin tools: 5 QPS). See mureo/throttle.py.
  • Meta token auto-refresh — Long-Lived Tokens are automatically refreshed when 53+ days old (requires app_id and app_secret). See mureo/auth.py.

Coding Standards

  • Python 3.10+
  • PEP 8 compliance
  • Type annotations on all function signatures
  • frozen=True on all dataclasses
  • File size limit: 800 lines
  • Function size limit: 50 lines
  • Formatter: black (line-length 88)
  • Linter: ruff (select: E, F, I, N, W, UP, B, A, SIM, TCH)
  • Type checker: mypy (strict mode)

Commit Workflow

Before EVERY code commit, run a code-review agent — no exceptions, including fixup and review-response commits.

  1. After git add, before git commit, invoke python-reviewer (or the language-appropriate code-reviewer) via the Agent tool.
  2. For changes touching security boundaries (auth, OAuth, input validation, FS I/O of external input), also run security-reviewer in parallel.
  3. If the reviewer reports HIGH or CRITICAL findings, fix them and re-run the review before committing — fixes can introduce new issues.
  4. Only commit after the review is clean.

A previous review on the same branch does NOT exempt a follow-up commit. Each commit needs its own review pass — fixup commits, lint cleanups, and review-response commits all qualify.

Exceptions (visual review is sufficient):

  • docs/, README*, or CHANGELOG.md only
  • pyproject.toml version bump only
  • Dependabot GitHub-Actions version bumps
  • Pure typo fixes in comments / docstrings (no logic change)

Not exceptions:

  • Any line of production code logic, even one line
  • Any test file change (test correctness is reviewed too)
  • Pure import-ordering edits

This rule was reinforced after PR #20 (2026-04-19, OAuth helper extraction — 6 issues including 2 HIGH found post-hoc) and PR #75 (2026-05-01, fixup commit pushed without re-review). "Tests green" and "lint clean" are NOT substitutes for code review.

Test Coverage

  • Target: 80% minimum (enforced by tool.coverage.report.fail_under)

  • Mutation check for the delivery-collapse detector: python scripts/mutation_check.py injects ~20 plausible wrong implementations and asserts the tests object to each. Green tests prove the code passes its tests, not that the tests would notice if the code were wrong — and for a detector whose failure mode is silence on a dead account, "the suite is green" is exactly the reassurance that hides the bug. Two CRITICALs in #546 shipped past a green suite. Run it when touching mureo/analysis/delivery_collapse*.py, collapse_diagnosis.py, or any get_daily_delivery_report. A survivor is a gap in the tests, not a pass: add the missing assertion rather than deleting the mutation. tests/test_mutation_harness.py asserts every anchor still resolves, so a refactor cannot turn the harness into a no-op that always "passes".

  • Framework: pytest + pytest-asyncio

  • Run the suite in a clean virtualenv (mureo + [dev], nothing else). The root tests/conftest.py also points the home directory at a temp dir and hides any installed mureo.runtime_context_factory entry point for every test, so a credentials writer called without a path cannot reach the real ~/.mureo/credentials.json or a plugin's shared store (#739)

  • All external API calls (Google Ads, Meta Ads) must be mocked in tests

  • Use @pytest.mark.unit / @pytest.mark.integration for categorization

  • Async test mode: auto (asyncio_mode = "auto")

  • Browser assets (mureo/_data/web/): they ship as plain <script>-loaded files, so nothing there may grow a bundler, a module system or a build step. Rendering is guarded by the substring pins in tests/test_web_assets_*.py. DOM-free logic lives in its own asset next to dashboard.js, one global per file:

    • reports_logic.js → window.MUREO_REPORTS_LOGIC — the money-safety decisions (KPI withholding, freshness aggregation, conflict routing).
    • reports_format.js → window.MUREO_REPORTS_FORMAT — the display vocabulary (flag labels and severities, param detail, numbers, periods, and which of a stored report's fields are headline figures).
    • reports_order.js → window.MUREO_REPORTS_ORDER — the Reports index card order (localStorage, and the two ways the grid is reordered).
    • reports_triage.js → window.MUREO_REPORTS_TRIAGE — the multi-client triage layer (#651): which clients need attention, the ranking (REPORTS_TRIAGE_KINDS — the array index IS the rank), and what to run about each — plus how the layer is SHOWN: one row per kind, the top few of them, and which rows an operator has closed (keyed to a fingerprint of what the row said, so a changed finding comes back). Reads MUREO_REPORTS_LOGIC off the page at call time, so it loads after it.
    • reports_overview.js → window.MUREO_REPORTS_OVERVIEW — the Reports index view's own decisions: which view the section shows (the left menu always means the client list; a period switch or a status refresh keeps the operator where they are), the portfolio figures above the grid, and the "what mureo did today" feed (dated by the summary's server_today — this module never asks the browser what time it is).
    • reports_index.js → window.MUREO_REPORTS_INDEX — the list screen's whole model, composed once per render from the rows and the summaries already fetched (buildReportsIndexModel): the alert layer, the per-client health in grid order, the health split, the band and the portfolio figures. It decides nothing itself — it composes reports_triage.js, reports_overview.js and reports_hero.js, read off the page at call time, so it loads after all three. healthByIndex is the one place triageClientHealth is run per client, and buildReportsIndexModel the one place triageHealthCounts is called: the cards and the roster rows are handed that array, and the band, the chips and the portfolio strip are handed the counts built from it. No dashboard_reports*.js scans the alert layer for a health (#715). It also counts how many of the roster mureo could not fetch at all (unreachable, #714) — a fact about the REQUEST, stated in one line under the band; those clients keep the bucket they had, so nothing below this module grades a client on a failed fetch.

    Each carries an inert module.exports tail so node --test tests/js/*.test.js executes the exact bytes the browser is served, and each is listed in _STATIC_ALLOWLIST with its <script> ahead of dashboard.js (which throws at load naming any that is missing). tests/js/browser_contract.test.js asserts the shipping contract for every module from one table — add a row when you add a module.

    Interaction tests. tests/js/dom_harness.js (a helper, not a suite) parses the real app.html, evaluates the real modules and dashboard.js against it, and answers "would an operator SEE this?" from the real app.css — modelling the one piece of cascade that decides it, the UA's [hidden] { display: none } versus an author display. Use it whenever a change is about what a click DOES: the v0.12.0 health filter shipped green because every test asserted hidden was set on the right cards, and .reports-client-card-item { display: flex } meant nothing moved. Anything hidden by setting .hidden needs a [hidden] rule of its own; that list is asserted in tests/js/reports_filter_interaction.test.js. Put new decision logic in one of these rather than in dashboard.js; a grep-pin cannot catch an inverted condition.

Credential Security

  • Never hardcode API keys, tokens, or secrets in source code
  • Credentials are loaded from ~/.mureo/credentials.json (priority) or environment variables (fallback)
  • Google Ads: GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN
  • Meta Ads: META_ADS_ACCESS_TOKEN, META_ADS_APP_ID, META_ADS_APP_SECRET
  • Amazon Ads: AMAZON_ADS_CLIENT_ID plus either AMAZON_ADS_ACCESS_TOKEN or both of AMAZON_ADS_REFRESH_TOKEN / AMAZON_ADS_CLIENT_SECRET; optional AMAZON_ADS_REGION, AMAZON_ADS_ACCOUNT_MODE, AMAZON_ADS_PROFILE_ID, AMAZON_ADS_ACCOUNT_ID, AMAZON_ADS_MANAGER_ACCOUNT_ID
  • The auth_setup.py wizard writes credentials to ~/.mureo/credentials.json
  • No log line may carry a token, secret or credential value — log the field name, the exception type, or a scrubbed detail, never the value. Since #581 the configure server installs a real handler (mureo/logging_setup.py → ~/.mureo/logs/configure.log, MUREO_LOG_LEVEL to raise it), so a leaky logger.* call is now a leak on disk rather than a dead line. HTTP access logs go through safe_http_log_line, which drops query strings (an OAuth callback carries ?code=)

BYOD Mode (Bring Your Own Data)

mureo byod import <file>.xlsx lets a user analyze their real ad-account data locally without OAuth. The bundle importer (mureo/byod/bundle.py) opens the workbook with openpyxl, dispatches recognized adapters by header signature, and writes per-platform CSVs under ~/.mureo/byod/<platform>/. When ~/.mureo/byod/manifest.json registers a platform, mureo/mcp/_client_factory.py routes that platform's MCP tool calls to a CSV-backed client (mureo/byod/clients.py) instead of the live API. Per-platform: imported platforms = BYOD; un-imported = Live API (or _no_creds error if no credentials). Supported BYOD platforms: google_ads, meta_ads. GA4 and Search Console remain on the Live API OAuth path.

When working on mureo:

  • New ad-platform handlers MUST call mureo/mcp/_client_factory.get_*_client(...) for the BYOD path; real-mode dispatch can call create_*_client directly to keep test mocks at mureo.mcp._handlers_*.create_*_client working.
  • BYOD clients are read-only. Mutation method name prefixes (create_, update_, delete_, pause_, resume_, enable_, disable_, apply_, publish_, submit_, attach_, detach_, approve_, reject_, cancel_, set_, patch_, add_, remove_, send_, upload_, boost_, end_, duplicate_, export_) return {"status": "skipped_in_byod_readonly"}. The authoritative list is _MUTATION_PREFIXES in mureo/byod/clients.py (26 prefixes).
  • New bundle adapters live in mureo/byod/adapters/<platform>.py and must implement has_tab(workbook) (returns True when the workbook contains the adapter's required sheet/header signature) and normalize_from_workbook(workbook, dst_dir) (writes CSVs to dst_dir, returns an ImportResult). Both adapters (Google Ads and Meta Ads) import the shared helper sanitize_cell from mureo/byod/adapters/_csv_safe.py and apply it to every user-controlled cell against CSV injection (it prefixes formula triggers =, +, -, @, tab, CR with a single quote), then write to the mureo internal schema documented in docs/byod.md.
  • Bundle imports MUST never escape ~/.mureo/byod/ — bundle.py and installer.remove_platform both enforce path-traversal guards.
  • See docs/byod.md for the user-facing walkthrough and the recommended Saved Report configuration for Meta Ads (multilingual: 9 locales).

GAQL Injection Prevention

Google Ads queries use GAQL (Google Ads Query Language). When constructing queries:

  • Never interpolate user input directly into GAQL strings
  • Use parameterized values for customer_id, campaign_id, etc.
  • Validate ID formats before use (numeric strings only)
  • Route all IDs/dates through the dedicated validator mureo/google_ads/_gaql_validator.py. Its ID pattern uses ASCII [0-9]+ (not \d), so full-width / Unicode digits (e.g. 123, ٣٤٥) are rejected rather than silently accepted and interpolated (#441).