Skip to content

Capabilities (read vs. write)

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

Not every database write is an Amazon mutation. Dynamic-dashboard artifacts, notification state, and other Pulsy-local records have their own routes. Report schedule changes are local writes but still use the approval-request flow below.

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). The current action-chat read tool list is intentionally narrower than the full upstream agent router:

  • Amazon Ads entities — the LLM-facing factories currently register campaign list/detail, keyword list, ad-group list, portfolio list, target list/detail and unified target query, plus action history. Campaign listing supports an ad-type filter; the tool list does not currently expose a generic ads or negative-keywords read tool. These tools are built in ai-api/app/agents/tools/amazon_ads_tools.py and call the scoped Amazon Ads proxy.
  • 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 — a three-step read/analysis flow that identifies declining campaigns, analyzes likely causes, and returns candidate actions; performance_diagnose_tool and perf_candidates_tool do not apply a mutation.
  • AMC — query results and CSV preview (agent_amc.py), with execution progress reported separately from the final result.
  • Dynamic dashboards — planning a dashboard DSL, reading a scoped artifact, running a preview, and recording execution history. The preview resolves one active profile from the allowed profile set and fails closed when team/country or profile scope is incomplete (app/routers/pulsy/dynamic_dashboard.py).
  • Persona Builder, when enabledpersona_builder_build builds a DSP audience-targeting expression and returns a banded-size insight; the combined audience status lookup is also read-only. Registration is opt-in via ENABLE_PERSONA_BUILDER_TOOLS (default off) and requires country scope.
  • Reports and history — report context, optimize-cycle/execution history, and campaign/action 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.”

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.)

The current Amazon mutation bundle contains 12 tools. In addition, there are approval-backed, opt-in action tools for:

  • Persona Builder DSP audiences — create a combined audience or delete an audience. The create path has a test-name safety check and both live mutations use @require_approval (persona_builder_tools.py).
  • Reach Planning — create an Amazon DSP reach, performance, or deduplicated-reach forecast. The tool is controlled by ENABLE_REACH_PLANNING_TOOLS (default off), requires team + country scope, and deliberately has no dry-run/preview path: it proposes first and calls Amazon only after approval (reach_planning_tools.py). A successful result with the same request hash can be served from stored cache instead of creating a second forecast.
  • Report schedules — create or update a custom report-template schedule. These are Pulsy-local database writes, not Amazon writes, but the reporting tools also use @require_approval.

None of the approval-backed tools execute their external change inline. Amazon campaign changes pass three gates in series; local report schedules still pass the approval gate.

Amazon mutation, reporting, Persona, and Reach Planning 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 has a default 5-minute TTL, configurable with ACTION_APPROVAL_EXPIRE_MINUTES; 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 for campaign mutations

Section titled “Gate 2 — the viewer-role block for campaign mutations”

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.

This is a campaign-specific role block, not a claim that every approval-backed tool shares the same authorization policy. The newer Persona and Reach Planning tool names are outside MUTATION_TOOL_NAMES; whether the viewer policy should cover those high-risk DSP actions is still an ops question.

Gate 3 — backend validation, dry-run, and brand isolation

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

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.
  • Brand context isolationshared/guardrails.py provides brand_scope/assert_brand_scope so a scoped execution can reject an access to a different brand. The helper is permissive when no scope has been set for legacy callers; it is not a substitute for request scoping.

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; the rest are preview/stub paths.

Two related facts:

  • The direct chat adjust-budget path has no such whitelist — once approved and not a dry-run, it is live for any brand. Whether that asymmetry is intentional is an ops question.
  • 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 checks.

Persona audience creation/deletion and Reach Planning forecast creation are not autonomous optimize-cycle changes: they are opt-in action-chat tools whose live execution remains behind @require_approval. Reach Planning’s approved call is a real create, not a dry-run.

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 current aop-tools registry contains 23 formal categories and 236 catalogued action entries. These are catalogued operator assets, not a Pulsy endpoint count and not all automatically registered as direct Hermes tools.

AOP catalog surfaceCurrent action entries / scope
Amazon Ads72: 4 high-level operations, 22 reads, and 46 writes across SP/SB/DSP campaigns, ad groups, ads, creatives, assets, audiences, keywords, targets, portfolios and flights.
AMC and analyticsAMC query/execution helpers; performance, cross-source, insight, rank, sales, inventory and product analytics.
Planning and optimizationPacing, performance targets, product/campaign groups, targeting groups, and RTB evidence, scoring, parameter-group and registration operations.
Operations and workspaceControl Tower account/readiness/attention/goal operations, profiles/entities/products, memory, reports, task management, librarian/migration utilities, Meta Ads, off-Amazon sync, and Google Workspace helpers.

The AOP write boundary is different: actions use their own dryRun, preview, submit, commit, apply, or confirm parameters where defined. There is no shared Pulsy propose→approve valve. aop-hermes/docs/DESIGN.md also makes clear that the asset/catalog tree is broader than the subset registered as default Hermes tools, and that HTTP-backed tools keep raw transport paths inside the tool rather than exposing them in the LLM schema.

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.
  • Whether the viewer-role campaign guard should be extended to the new approval-backed Persona and Reach Planning DSP tools.