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.pyrejects anything not starting withSELECT/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:
Gate 1 — the human approval valve
Section titled “Gate 1 — the human approval valve”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:
- writes a pending
action_requestrow (the normalized change), - emits an
:::approvalcard into the chat and pauses the turn — the tool returns a summary without applying anything, - applies only when a human calls
POST /pulsy/action-chats/approve, which dispatches the approved change topulsead-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.
Gate 2 — the viewer-role block
Section titled “Gate 2 — the viewer-role block”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_mutationshort-circuits whendry_runis set: it never calls Amazon, returns a preview, and recordsstatus="dry_run". - Budget guardrails — the per-action budget handler (e.g.
adjust_budget) callsshared/guardrails.py:validate_budget_change, capping a change at ≤50% relative, ≤$10,000 absolute, ≥$1 floor. - Approval state —
validate_approval_before_executionrequires anapproved/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-budgetpath 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_approvalrow 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_ROLEis also read-only at the Snowflake grant level (code only enforces theSELECT/WITHguard). - Which brands/stages have auto-approval turned on (lives in the
pipeline_auto_approvaltable, not in code). - Whether the un-whitelisted direct
adjust-budgetpath is intentional or should match the optimize-cycleKISS-only gate.