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.pyand 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.pyrejects anything not starting withSELECT/WITH. - Performance diagnosis — a three-step read/analysis flow that identifies
declining campaigns, analyzes likely causes, and returns candidate actions;
performance_diagnose_toolandperf_candidates_tooldo 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 enabled —
persona_builder_buildbuilds a DSP audience-targeting expression and returns a banded-size insight; the combined audience status lookup is also read-only. Registration is opt-in viaENABLE_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.”
The approval-backed write surface
Section titled “The approval-backed write surface”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.
Gate 1 — the human approval valve
Section titled “Gate 1 — the human approval valve”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:
- 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 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_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. - Brand context isolation —
shared/guardrails.pyprovidesbrand_scope/assert_brand_scopeso 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-budgetpath 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_approvalrow 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 surface | Current action entries / scope |
|---|---|
| Amazon Ads | 72: 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 analytics | AMC query/execution helpers; performance, cross-source, insight, rank, sales, inventory and product analytics. |
| Planning and optimization | Pacing, performance targets, product/campaign groups, targeting groups, and RTB evidence, scoring, parameter-group and registration operations. |
| Operations and workspace | Control 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_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. - Whether the viewer-role campaign guard should be extended to the new approval-backed Persona and Reach Planning DSP tools.