Skip to content

Capabilities (read vs. write)

The one rule that predicts everything here: Pulsy reads freely; every Amazon write is proposed, not executed, and applied only after it clears three gates. A chat reply that says “I’ll cut that budget by 20%” means a proposal is waiting — not that anything changed.

This page is the internal source of truth for that boundary. It’s written from the code (ai-api + pulsead-agents); the symbols are cited so you can verify.

The read surface (no gate beyond access scoping)

Section titled “The read surface (no gate beyond access scoping)”

Reads run inline during a chat turn and need no approval — only a resolved scope (team + country + an active profile). They cover:

  • Amazon Ads entities — list/get campaigns, ad groups, ads, targets, keywords, negative keywords, portfolios (SP/SB), via pulsead-agents/shared/amazon_ads.py. These hit Amazon’s read endpoints (/adsApi/v1/query/*).
  • The warehouse — analytics and historical metrics from Snowflake. The query path is read-only by construction: pulsead-agents/shared/snowflake.py rejects anything not starting with SELECT/WITH.
  • Performance diagnosis — ROAS/ACOS analysis and candidate-entity detection (ai-api/app/services/pulsy/performance_diagnose_service.py).
  • AMC — query results and CSV preview (agent_amc.py).
  • History — optimize-cycle, execution, and campaign history aggregators.

Reads fail closed: if scope can’t be resolved, you get empty/​not-permitted, not a broader default. Empty results often mean “scope not resolved,” not “no data.”

The write surface (everything here is gated)

Section titled “The write surface (everything here is gated)”

Pulsy can propose these Amazon changes — adjust budget, adjust bids, pause campaign(s), update campaigns, create/update/pause keywords, create/update targets, create a campaign, and execute an optimize-cycle rebalance. (Report schedules are also “writes,” but they touch only Pulsy’s own database, never Amazon.)

None of them execute inline. Each passes three gates in series:

Mutation tools are wrapped by @require_approval (ai-api/app/agents/tools/mutation_tools/_decorator.py). When the agent calls one, it does not run the change. It:

  1. writes a pending action_request row (the normalized change),
  2. emits an :::approval card into the chat and pauses the turn — the tool returns a summary without applying anything,
  3. applies only when a human calls POST /pulsy/action-chats/approve, which dispatches the approved change to pulsead-agents.

The pending request carries a ~5-minute TTL — approve too late and you get 410. The lifecycle is a state machine: pending → approved → executed | failed, plus canceled / edited / expired / superseded. So “expired,” “already applied,” and “canceled” are expected outcomes, not bugs.

ai-api/app/services/pulsy/campaign_mutation_guard.py raises 403 (VIEWER_MUTATION_FORBIDDEN) if a viewer-role member tries to approve a campaign mutation. The gated set is MUTATION_TOOL_NAMES; reads (ads_query_*) and report-schedule writes are deliberately excluded, so a viewer can still approve those.

Gate 3 — backend validation, dry-run, and the brand whitelist

Section titled “Gate 3 — backend validation, dry-run, and the brand whitelist”

Approved changes funnel through pulsead-agents (web/backend/routers/ads.py):

  • Dry-run_execute_mutation short-circuits when dry_run is set: it never calls Amazon, returns a preview, and records status="dry_run".
  • Budget guardrails — the per-action budget handler (e.g. adjust_budget) calls shared/guardrails.py:validate_budget_change, capping a change at ≤50% relative, ≤$10,000 absolute, ≥$1 floor.
  • Approval statevalidate_approval_before_execution requires an approved/auto_approved, non-expired request.

Live vs. preview — the caveat that matters most

Section titled “Live vs. preview — the caveat that matters most”

Autonomous (non-chat) optimize-cycle budget changes are live for exactly one pilot brand today. pulsead-agents/shared/oc_live_brands.py hardcodes LIVE_BUDGET_UPDATE_BRANDS = {"KISS"}. For every other brand the optimizer computes the decision and writes it to oc_allocations, but sends nothing to Amazon (it records "success" anyway). So when someone says “Pulsy optimizes budgets automatically,” the honest internal statement is: for one pilot brand; preview/stub for the rest.

Two related facts:

  • The direct chat adjust-budget path has no such whitelist — once approved and not a dry-run, it’s live for any brand. (Whether that asymmetry is intentional is a question for ops — see below.)
  • Auto-approval can skip the human click when a pipeline_auto_approval row enables it for a brand/stage. It still passes the guardrails and the whitelist — it removes the click, not the safety.

Pulsy is not AOP — don’t conflate the surfaces

Section titled “Pulsy is not AOP — don’t conflate the surfaces”

aop-hermes (the operator cockpit, in the name map) also drives Amazon Ads, but it is a separate tool for PulseAd staff, not the Pulsy product. Its write surface is much larger (~45 create/update/delete actions across campaigns, ad groups, targets, keywords, ads, creatives, audiences) and is gated only by per-tool dry-run flags — there is no propose→approve valve. When this doc says “Pulsy can write X under approval,” that is the gated product surface; AOP’s ungated operator surface is a different thing.

Known gaps — verify with ops (not asserted here)

Section titled “Known gaps — verify with ops (not asserted here)”

These are factual unknowns the code doesn’t settle; confirm before treating as fact:

  • Whether the configured SNOWFLAKE_ROLE is also read-only at the Snowflake grant level (code only enforces the SELECT/WITH guard).
  • Which brands/stages have auto-approval turned on (lives in the pipeline_auto_approval table, not in code).
  • Whether the un-whitelisted direct adjust-budget path is intentional or should match the optimize-cycle KISS-only gate.