diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/.gitignore b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/.gitignore new file mode 100644 index 000000000..372e1321f --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/.gitignore @@ -0,0 +1,7 @@ +# Privy Wallet Hub frontend. +# The notebook clones https://github.com/privy-io/aws-agentcore-sdk +# into `privy-delegation/` on first run (see §4.5e). The folder is a +# vendored upstream tree, not part of this sample, so we never commit +# it back. This rule keeps it out of `git status` for contributors who +# run the notebook locally. +privy-delegation/ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/README.md b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/README.md new file mode 100644 index 000000000..e89e41aeb --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/README.md @@ -0,0 +1,395 @@ +# Pay-For-API + +## Overview + +**Amazon Bedrock AgentCore Payments** enables AI agents to make autonomous +payments for digital services. Agents never hold private keys or require +human approval for each transaction. + +This use case builds two Strands agents that buy metered access to a paid +HTTP API through AgentCore Payments. One agent signs on the Ethereum +Virtual Machine (EVM) (Base Sepolia) and the other on Solana (Solana +Devnet). The seller is a minimal "Fun Facts" service deployed via AWS +CDK: an Amazon API Gateway HTTP API backed by an AWS Lambda function +that charges **$0.01** per call and accepts either network in the x402 +response. + +When an agent requests a fact, the seller returns HTTP 402 with a +payment requirement. The agent forwards the requirement to AgentCore +Payments' `ProcessPayment` operation and receives a signed proof. It +then retries the request with the proof attached and returns the paid +fact. The agent is designed so it never needs to touch a private key. + +Internally, AgentCore Payments manages the wallet, the signing keys, +and the on-chain settlement. Whether the `PaymentManager` is wired to +**Coinbase Developer Platform (CDP)** or **Stripe via Privy**, the +agent code is identical. The service picks the right signer from the +connector tied to the instrument. + +This notebook is **self-contained**. It provisions a full AgentCore +Payments stack inline (§5), creates two `EMBEDDED_CRYPTO_WALLET` +instruments under the same connector (ETHEREUM + SOLANA), and deploys +the seller from a CDK stack that lives alongside it (§3). If a +`PaymentManager` and at least one `PaymentInstrument` already exist, +the notebook detects them in §4 and skips the inline setup. + + +### Use Case Details + +| Information | Details | +|:--------------------|:----------------------------------------------------------------------| +| Use case type | Agentic HTTP API consumption with autonomous micropayment | +| AgentCore components| Amazon Bedrock AgentCore Payments | +| Wallet providers | Coinbase CDP ✅ · Stripe via Privy ✅ | +| Payment protocol | x402 (HTTP 402 Payment Required) on the wire | +| Agent type | Single | +| Agentic Framework | Strands Agents | +| LLM model | Anthropic Claude Sonnet 4.5 (Amazon Bedrock, `us.` inference profile) | +| Example complexity | Intermediate | +| SDK used | boto3 | + +### Architecture + +Three parties participate in every paid request: + +1. **Strands agent** — the only tool it calls is `http_request`. The + `AgentCorePaymentsPlugin` intercepts HTTP 402 responses and handles + the payment handshake transparently. +2. **Amazon Bedrock AgentCore Payments** — receives `ProcessPayment`, + returns a signed x402 proof using the wallet tied to the instrument + (Coinbase CDP or Privy). +3. **Seller (CDK stack)** — AWS Lambda function behind Amazon API + Gateway that issues the 402 challenge, verifies the proof, and + serves the content. + +Four IAM roles separate concerns operationally, following the +**principle of least privilege**: each role has only the permissions +required for its specific operation, with explicit `Deny` statements +on actions reserved for other roles: + +- `AgentCorePaymentsControlPlaneRole` — manages Manager, Connector, Credential Provider +- `AgentCorePaymentsManagementRole` — manages Instrument and Session (explicit `Deny` on `ProcessPayment`) +- `AgentCorePaymentsProcessPaymentRole` — signs payments, reads Instrument and Session +- `AgentCorePaymentsResourceRetrievalRole` — assumed by AgentCore Payments at runtime to retrieve credentials + +`test/integration/setup-roles.sh` creates all four with the right +policies. See the public [IAM roles for AgentCore Payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html) +reference for the full policy details and an explanation of the +separation-of-duties model. + +
+ Pay-for-API architecture diagram: a user prompts a Strands agent on AgentCore Runtime, the agent calls a paid HTTP API on Amazon API Gateway plus AWS Lambda, the seller returns HTTP 402 with a payment requirement, AgentCore Payments signs the payment via Coinbase CDP or Stripe via Privy, the agent retries with the signed proof, the seller settles on chain through the x402 facilitator and returns 200 OK, and the operator audits spend through GetPaymentSession. +
+ +**Numbered flow (matches the diagram)** + +1. **User** sends a query to the **Agent** (AgentCore Runtime + Strands). +2. The agent calls the paid API hosted on **Amazon API Gateway** → **AWS Lambda**. +3. The seller responds with **HTTP 402 Payment Required** and a payment requirement payload. +4. The agent forwards the requirement to **AgentCore Payments**, which selects the + matching `PaymentInstrument`, checks the session budget, and signs the payment + through the configured wallet provider (Coinbase CDP or Stripe via Privy). +5. The agent retries the request with the signed `X-PAYMENT` header. The seller + verifies, settles on-chain through the x402 facilitator, and returns **200 OK** with the content. +6. The agent answers the user. The operator audits spend through `GetPaymentSession`. + +### Use Case Key Features + +* Agent is designed not to hold private keys — AgentCore Payments + signs every charge via the configured `PaymentManager` and + `PaymentConnector` +* Wallet-provider-agnostic — the same agent code runs against a Coinbase CDP + instrument or a Stripe-via-Privy instrument +* Human-controlled budget via `maxSpendAmount` on the payment session +* IAM role separation: `ManagementRole` creates sessions, `ProcessPaymentRole` signs + payments (explicit `Deny` in both directions, enforced by IAM rather than + documentation) +* Full audit trail via `GetPaymentSession` — the operator sees exactly what the + agent spent +* Self-contained — the notebook runs from a clean AWS account + +--- + +## Payment Protocol Availability + +AgentCore Payments supports multiple wallet providers. The wire format +(x402 for crypto settlement) is an implementation detail. The agent +code in this use case does not change based on provider. The service +picks the right signer from the connector tied to the instrument. + +| Wallet Provider | Connector Type | Status | Notes | +|:----------------|:---------------|:-------|:------| +| **Coinbase CDP** | `CoinbaseCDP` | ✅ Available — EVM + Solana | API Key ID, API Key Secret, Wallet Secret. **Enable "Delegated signing"** under Project → Wallet → Embedded Wallets → Policies before use. Inline setup in §5 provisions a Coinbase CDP wallet. | +| **Stripe** (via Privy) | `StripePrivy` | ✅ Available — EVM + Solana | App ID, App Secret, Authorization Key ID, P-256 Authorization Private Key. Privy returns the private key prefixed with `wallet-auth:` — **strip the prefix** before storing it. Inline setup in §5 provisions a Privy-backed wallet. No hub redirect is needed for Privy: the authorization key registered on the credential provider is the signing delegation. | + +--- + +## Prerequisites + +- **AWS account** with Amazon Bedrock AgentCore Payments available in your chosen region +- **Amazon Bedrock access** enabled for **Anthropic Claude Sonnet 4.5** in your chosen region (cross-region inference profile `us.anthropic.claude-sonnet-4-5-20250929-v1:0`) +- **Python 3.10+** with a Jupyter kernel. If you hit "Running cells requires the ipykernel package", install it once: `python3 -m pip install ipykernel --user`. Any Jupyter frontend works — JupyterLab (4.0+), classic Jupyter Notebook (7.0+), VS Code, or Kiro. +- **AWS Command Line Interface (AWS CLI) v2** configured with credentials (`aws configure`) +- **AWS Cloud Development Kit (CDK) v2** installed globally (`npm install -g aws-cdk`); used by the notebook to deploy the seller +- **Node.js 18+** — required by CDK +- **A wallet provider account** — Coinbase Developer Platform (CDP) (API Key ID, API Key Secret, Wallet Secret) or Stripe via Privy (App ID, App Secret, Authorization Key ID, P-256 Authorization Private Key) +- **Testnet USD Coin (USDC)** from the [Circle testnet faucet](https://faucet.circle.com/) on both **Base Sepolia** and **Solana Devnet**, because §5 creates one wallet per network + +--- + +## Security + +The use case relies on AgentCore Identity's **payment credential provider** +to manage wallet provider secrets. Once `CreatePaymentCredentialProvider` +runs in §4, AgentCore Identity stores the Coinbase / Privy API keys, app +secrets, and wallet or authorization secrets in **AWS Secrets Manager**, +encrypts them with **AWS Key Management Service (KMS)** keys, and surfaces +only the secret ARN to your agents (see [Configure credential +provider](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/resource-providers.html)). +The agent runtime calls `GetResourcePaymentToken` at signing time to +receive a short-lived vendor-specific token; it never sees the raw API key +or wallet secret. + +What AgentCore Payments handles for you: + +- **Secret storage** — wallet provider secrets land in AWS Secrets Manager + under AgentCore Identity, encrypted with AWS-owned KMS keys (customer- + managed KMS keys supported) +- **Secret retrieval** — agents call `GetResourcePaymentToken` and receive a + vendor token. The agent runtime never receives the underlying API key, + app secret, or wallet secret +- **Audit trail** — every `ProcessPayment` call writes to AWS CloudTrail + and to the AgentCore Payments managed log group. Use `GetPaymentSession` + for operator-visible spend totals +- **Budget enforcement** — the operator sets `maxSpendAmount` on the + payment session. AgentCore Payments rejects any `ProcessPayment` that + would exceed it +- **IAM least privilege** — the four roles in §2 each receive only the + actions and resources required for one operation. Cross-role permissions + are explicitly denied (`ManagementRole` cannot call `ProcessPayment`, + `ProcessPaymentRole` cannot manage sessions or instruments) + +What you handle locally: + +- **Initial credential paste** — Coinbase / Privy secrets are pasted into + `.env` once, before §4 runs. The notebook reads them only to call + `CreatePaymentCredentialProvider`. After that call returns, the secrets + are inside the AgentCore Identity-managed vault (Secrets Manager) and + the local `.env` copies are no longer needed by the agent. They remain + in `.env` so re-running §4 is idempotent +- **Encryption in transit** — all calls to AgentCore Payments, Amazon + Bedrock, and the seller HTTP API run over TLS (`https://`). The + Dockerfile health check is the only HTTP URL and is loopback-only + +### Production hardening + +This is an L100 tutorial. Before deploying anything resembling this +sample to production: + +- **Drop `.env` after first run.** Once §4 has called + `CreatePaymentCredentialProvider`, blank the secret values from `.env`. + Subsequent notebook runs read the credential provider ARN from `.env` + (which is non-sensitive) and the actual secrets stay in Secrets Manager +- **Use customer-managed KMS keys.** AgentCore Identity defaults to + AWS-owned KMS keys; switch to customer-managed keys for additional + audit and rotation control +- **Tighten IAM role wildcards.** Once Manager IDs are stable, replace + `payment-manager/*` with the specific Manager ARN, or scope by tag +- **Switch the AgentCore Runtime to VPC mode** with private subnets and + VPC endpoints for AWS APIs (the tutorial uses `networkMode=PUBLIC`) +- **Restrict the seller's Amazon API Gateway CORS** to the specific agent + runtime domains that need to call it +- **Pin the `bedrock-agentcore` Python SDK and `@x402/*` Node packages** + to specific versions in production builds + +--- + +## Running the Use Case + +Before opening the notebook, create a Python virtual environment so +dependency installs and notebook state stay isolated from the global +Python. + +**Option 1 — Terminal (cross-platform)** + +```bash +python3 -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +python3 -m pip install --upgrade pip ipykernel +python3 -m ipykernel install --user --name pay-for-api-venv --display-name "Python (pay-for-api-venv)" +``` + +**Option 2 — VS Code / Kiro** + +1. Open `pay-for-api.ipynb`. +2. Choose the kernel selector in the top-right of the notebook (or the + Python version indicator in the bottom status bar). +3. Choose **Python: Create Environment...**. +4. Choose **Venv**. +5. Pick a Python 3.10+ interpreter. The IDE creates `.venv/` and selects + it automatically. +6. When prompted to install kernel dependencies (`ipykernel`), accept. + +After the venv is active, open `pay-for-api.ipynb` and run cells in +order. The CLI equivalent of opening the notebook is: + +```bash +jupyter notebook pay-for-api.ipynb +``` + +The notebook handles dependency install, IAM role creation, credential +prompts, seller deploy, payment provisioning, agent runs, and teardown: + +- §1 installs the Python dependencies from `requirements.txt` +- §2 creates the four IAM roles and interactively prompts for wallet provider credentials (Coinbase CDP or Stripe via Privy) +- §3 deploys the Fun Facts seller stack via CDK and captures the URL +- §4 decides whether to run inline setup or reuse existing AgentCore Payments infrastructure +- §5 provisions a Credential Provider + Manager + Connector for the chosen provider, then creates two Payment Instruments (ETHEREUM + SOLANA) under the same connector +- §6 creates two budget-limited payment sessions, one per network +- §7 builds the Strands agent factory: one pattern that wraps the `AgentCorePaymentsPlugin` around whichever (instrument, session, network) is passed in +- §8 runs the agent once on EVM and once on Solana against the same seller +- §9 optionally deploys the agent to AgentCore Runtime via `agent/cdk/` and invokes it remotely +- §10 inspects the data plane for both networks: GetPaymentSession, balance, ListPaymentInstruments, ListPaymentSessions +- §11 tears everything down: sessions, seller stack, agent runtime (if §9 was run), and AgentCore Payments resources (optional) + +--- + +## Key Notes + +- The seller stack deploys to the same region as AgentCore Payments — + set by `AWS_REGION` in `.env`. +- USDC amounts use 6 decimal places: `"$0.01"` → `10000` atomic units + on the wire. The `@x402/hono` library handles the conversion. +- The seller emits multi-network `accepts` — one entry for EVM + (Base Sepolia) and one for Solana (Devnet) when both payout wallets + are configured. The agent picks the entry matching the instrument's + network. +- Responses use the `{ x402_content, x402_meta }` shape so the seller + is discoverable through the AgentCore Registry / Bazaar Model + Context Protocol (MCP). +- The `ProcessPaymentRole` has an explicit IAM `Deny` on all session + and instrument management; the `ManagementRole` has an explicit + `Deny` on `ProcessPayment`. The trust boundary is enforced by IAM, + not by documentation. +- The seller verifies payment proofs against the public x402 + facilitator (`https://x402.org/facilitator`). Point it at a private + facilitator by editing `seller/lambda/index.js` and redeploying. +- When a `StripePrivy` instrument is used, the agent and the seller do + not change. AgentCore Payments routes the signing request to Privy's + key-management service transparently. Privy-backed instruments + settle on both EVM (Base / Base Sepolia) and Solana (Solana / Solana + Devnet). +- The agent never calls the plugin's read-only management tools + (`get_payment_instrument`, `list_payment_instruments`, + `get_payment_session`). Those are reserved for operator debug flows. + The system prompt in §7 tells the model to use only `http_request`. + +--- + +## Cleanup + +> ⚠️ **Cost notice:** The resources deployed in this use case incur +> AWS charges while running. AWS Lambda, Amazon API Gateway, AgentCore +> Runtime, AgentCore Memory, and AgentCore Payments all bill on +> per-request and per-resource models. Run §11 of the notebook to tear +> them down when you are done. + +§11 of the notebook handles teardown end-to-end: + +| Step | What it does | What it removes | +|------|--------------|-----------------| +| Revoke session | `DeletePaymentSession` on each session created in §6 | Active session budgets (no undelete) | +| Tear down the seller stack | `cdk destroy` on the seller CDK app | Amazon API Gateway HTTP API, AWS Lambda function, IAM execution role | +| Tear down the agent runtime | `cdk destroy` on the agent CDK app (only if §9 was run) | AgentCore Runtime, AgentCore Memory, Amazon ECR repository, AWS CodeBuild project, IAM execution role | +| Tear down AgentCore Payments resources | Calls `DeletePaymentInstrument`, `DeletePaymentConnector`, `DeletePaymentManager`, `DeletePaymentCredentialProvider` in dependency order | All Manager / Connector / Instrument / Credential Provider resources created by §5 | +| Remove local build artifacts | Deletes `.venv/`, `cdk.out/`, `__pycache__/`, `outputs.json`, `privy-delegation/`, `seller/lambda/node_modules/` | Local working-copy files only — no cloud resources | + +The IAM roles created by `setup-roles.sh` in §2 have no standing cost +and are retained for re-runs. To delete them by hand: + +```bash +aws iam delete-role --role-name AgentCorePaymentsControlPlaneRole +aws iam delete-role --role-name AgentCorePaymentsManagementRole +aws iam delete-role --role-name AgentCorePaymentsProcessPaymentRole +aws iam delete-role --role-name AgentCorePaymentsResourceRetrievalRole +``` + +CloudWatch log groups under `/aws/bedrock-agentcore/` and `/bedrock-agentcore/payments/` +are retained after teardown so you can review historical traces. Delete +them from the CloudWatch console if you want to clear historical data. + +### Manual cleanup (without the notebook) + +If the notebook is unavailable, run the same teardown from a shell: + +```bash +# 1. Destroy the seller stack +bash test/integration/destroy-seller.sh + +# 2. Destroy the agent runtime stack (only if §9 was run) +bash test/integration/destroy-agent.sh + +# 3. AgentCore Payments resources require boto3 calls — see §11 of +# the notebook for the exact API sequence. +``` + +### Verify cleanup succeeded + +Confirm no CloudFormation stacks remain: + +```bash +aws cloudformation list-stacks \ + --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ + --query "StackSummaries[?starts_with(StackName, 'AgentCorePayments')].StackName" +``` + +The output should be empty. + +--- + +## Conclusion + +This use case demonstrates how Amazon Bedrock AgentCore Payments +enables an AI agent to make autonomous micropayments for paid HTTP APIs +without holding private keys or requiring per-transaction human +approval. The same agent code paid for the same content through two +different wallet providers (Coinbase CDP and Stripe via Privy) and on +two different networks (EVM and Solana), demonstrating the +provider-agnostic and network-agnostic design. + +Key takeaways: + +- **Separation of concerns** — IAM roles isolate session creation, + payment signing, and credential retrieval. The trust boundary is + enforced by IAM, not by code. +- **Budget control** — operators set a maximum spend per session. + AgentCore Payments enforces it, and `GetPaymentSession` provides a + full audit trail. +- **Wire format** — x402 (HTTP 402 Payment Required) is the open spec + on the wire. The `@x402/hono` library on the seller side and the + `AgentCorePaymentsPlugin` on the agent side handle the protocol so + that the application code remains a normal HTTP request. + +Use the [Learn more](#learn-more) links to go deeper, and adapt the +patterns in this notebook to your own paid-API integrations. + +--- + +## Learn more + +Public AgentCore Payments documentation: + +- [Overview](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html) +- [How it works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html) +- [Core concepts](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-concepts.html) +- [Prerequisites](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-prerequisites.html) +- [IAM roles](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html) +- [Set up a credential provider](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-setup-credential-provider.html) +- [Create a payment manager](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-manager.html) +- [Create a payment instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html) +- [Create a payment session](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-session.html) +- [Process a payment](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-process-payment.html) — plugin reference, interrupt contract, network preferences, `auto_payment=False` for human-in-the-loop flows +- [Connect to Bazaar](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-connect-bazaar.html) — make a seller discoverable through the AgentCore Registry + +Announcement: +[Agents that transact — Introducing Amazon Bedrock AgentCore Payments, built with Coinbase and Stripe](https://aws.amazon.com/blogs/machine-learning/agents-that-transact-introducing-amazon-bedrock-agentcore-payments-built-with-coinbase-and-stripe/) diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/README.md b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/README.md new file mode 100644 index 000000000..6baf98a69 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/README.md @@ -0,0 +1,146 @@ +# Pay-For-API — Buyer Agent + +A minimal Strands Agent, wired for Amazon Bedrock Claude Sonnet 4.5, +that buys a fact from the seller API by delegating the x402 payment to +**Amazon Bedrock AgentCore Payments** through the +`AgentCorePaymentsPlugin`. + +Two ways to run the same agent: + +| Mode | Where | When | +|------|-------|------| +| **Local** | Notebook cell in `pay-for-api.ipynb` (§8) | Teaching / fast iteration | +| **Runtime** | AgentCore Runtime container deployed via CDK (§9) | Production-shaped deploy | + +The agent code is identical in both modes. The container folder wraps +the same `Agent()` construction in a FastAPI `/invocations` endpoint +so it fits the AgentCore Runtime contract. + +## Prerequisites + +Before deploying the agent runtime, complete the parent use-case +prerequisites in [`../README.md`](../README.md). Specifically: + +- AWS account with Amazon Bedrock AgentCore Payments enabled in the + target region +- Amazon Bedrock model access for `us.anthropic.claude-sonnet-4-5-20250929-v1:0` +- AWS CDK v2 (`npm install -g aws-cdk`) and Node.js 18+ +- Python 3.10+ with the use-case venv active +- Completed §1-§6 of the parent notebook (so a `PaymentManager`, + `PaymentInstrument`, and at least one `PaymentSession` exist for the + runtime to invoke against) + +## Folder layout + +``` +agent/ +├── cdk/ +│ ├── app.py CDK app entry point +│ ├── agent_stack.py ECR + IAM + Runtime +│ ├── cdk.json +│ └── requirements.txt +├── container/ +│ ├── Dockerfile +│ ├── agent.py FastAPI server + Strands Agent +│ └── requirements.txt +└── README.md +``` + +## How the payment flow works + +1. The agent tries `http_request.GET /facts?topic=`. +2. The seller returns **HTTP 402** with an x402 `accepts` array. +3. `AgentCorePaymentsPlugin` intercepts the 402, calls + **`ProcessPayment`** against the configured Payment Manager, + Session, and Instrument, receives the signed `CRYPTO_X402` proof, + base64-encodes it into the `X-PAYMENT` header (per the x402 protocol + spec), and retries the request transparently. +4. The seller verifies the proof with the x402 facilitator, settles + on-chain, and returns the paid fact as **HTTP 200**. + +The agent never sees a private key, never assembles the `X-PAYMENT` +header, and never touches a boto3 client. The only tool it calls is +`http_request`. The plugin does also register three read-only +management tools (`get_payment_instrument`, +`list_payment_instruments`, `get_payment_session`) but the system +prompt in §7 of the notebook tells the model not to use them — they +are reserved for operator debug flows. + +## Identity model + +- Every payment operation runs under the **vendor-level user ID** from + `paymentInstrument.userId` — the value the service returns on + `CreatePaymentInstrument`. The notebook captures that ID and passes + it to the agent as `paymentUserId` on invocation. +- For Privy-backed instruments, this is the Privy DID. +- For Coinbase-backed instruments, this is the CDP end-user UUID (hub + flow). +- There is **no tenant/Cognito sub on the wire** — identity is + vendor-rooted end to end. + +## Deploy + +> ⚠️ **Cost notice:** This deploys an AgentCore Runtime, an Amazon ECR +> repository, an AWS CodeBuild project, an AgentCore Memory resource, +> and the supporting CloudWatch log groups. CodeBuild (per-build-minute) +> and the Runtime (per-invocation) are the highest-cost items. Run the +> [Clean up](#clean-up) steps when you are done. + +```bash +cd agent/cdk +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cdk bootstrap # only once per account/region +cdk deploy +``` + +Outputs: `AgentRuntimeArn`, `AgentRuntimeEndpoint`, +`AgentExecutionRoleArn`, `AgentEcrRepoUri`, +`AgentBuildProjectName`, `AgentMemoryId`. + +The notebook's §9 calls into the CDK for you. See +`pay-for-api.ipynb`. + +## Clean up + +Tear the runtime down when you no longer need it. The notebook's §11 +runs the same teardown plus the AgentCore Payments resource cleanup. + +```bash +bash test/integration/destroy-agent.sh +``` + +Or directly through CDK: + +```bash +cd agent/cdk +source .venv/bin/activate +cdk destroy +``` + +This removes the AgentCore Runtime, the AgentCore Memory resource, the +ECR repository (with its images), and the CodeBuild project. Verify by +listing CloudFormation stacks: + +```bash +aws cloudformation list-stacks \ + --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \ + --query "StackSummaries[?starts_with(StackName, 'AgentCorePaymentsBuyerAgent')].StackName" +``` + +The output should be empty. + +## Conclusion + +This folder packages the buyer-side half of the Pay-For-API use case +into a deployable AgentCore Runtime. The same Strands Agent pattern +runs locally in §7 of the parent notebook and in production-shaped +fashion through the CDK stack here, demonstrating how to graduate a +local agent prototype to a managed runtime without code changes. The +`AgentCorePaymentsPlugin` makes the x402 payment flow transparent to +the agent, so the same `http_request` tool call pays for content +through whichever wallet provider the operator configured. + +For a deeper walkthrough, run `pay-for-api.ipynb` end to end. For the +service-side reference, see the +[AgentCore Payments documentation](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html). diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/agent_stack.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/agent_stack.py new file mode 100644 index 000000000..2d398b891 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/agent_stack.py @@ -0,0 +1,587 @@ +"""Pay for API — buyer agent CDK stack. + +Provisions the full AgentCore Runtime stack for the buyer agent **without +requiring Docker on the machine running `cdk deploy`**: + +1. **Amazon S3 asset** — zips and uploads ``agent/container/`` to the CDK bootstrap + assets bucket. +2. **Amazon ECR repository** — destination for the built image. +3. **AWS CodeBuild project** — ARM64 Linux environment that pulls the S3 + asset, runs ``docker build``, and pushes to ECR. Runs in AWS, so the + caller needs only ``cdk deploy`` and AWS credentials. +4. **Build trigger AWS Lambda function** — custom resource that starts + the CodeBuild run and polls until the image is in ECR before the + Runtime resource is created. +5. **IAM execution role** with the minimum perms the runtime needs at + invoke time (Amazon Bedrock, AgentCore Payments data plane, Amazon + CloudWatch Logs, AWS X-Ray, Amazon CloudWatch Application Signals, + vended log delivery). +6. **AgentCore Runtime** pointing at the freshly-built image. + +Outputs the Runtime ARN, invoke URL, and execution role ARN so the +notebook can invoke the deployed agent by name. +""" + +from __future__ import annotations + +from pathlib import Path + +from aws_cdk import ( + CfnOutput, + CustomResource, + Duration, + RemovalPolicy, + Stack, + aws_bedrockagentcore as bedrockagentcore, + aws_codebuild as codebuild, + aws_ecr as ecr, + aws_iam as iam, + aws_lambda as aws_lambda, + aws_s3_assets as s3_assets, +) +from constructs import Construct + +# The container source lives in a sibling folder to cdk/ — resolve the +# absolute path once so S3 asset + docker build share the same context. +CONTAINER_DIR = str(Path(__file__).resolve().parent.parent / "container") + + +class AgentCorePaymentsBuyerAgentStack(Stack): + """AgentCore Runtime + IAM for the Pay for API buyer agent.""" + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # ── ECR repository ── + agent_repo = ecr.Repository( + self, + "AgentEcrRepo", + repository_name="pay-for-api-agent", + removal_policy=RemovalPolicy.DESTROY, + empty_on_delete=True, + lifecycle_rules=[ + ecr.LifecycleRule( + max_image_count=5, + description="Keep the 5 most recent images", + ) + ], + ) + + # ── S3 asset: zip of agent/container/ ── + # CDK uploads this to the bootstrap assets bucket on every + # `cdk deploy`. CodeBuild pulls it from S3 — no GitHub, no + # CodeCommit, no Docker-on-laptop. + agent_source = s3_assets.Asset( + self, + "AgentSourceAsset", + path=CONTAINER_DIR, + ) + + # ── CodeBuild project ── + build_project = codebuild.Project( + self, + "AgentBuildProject", + project_name="pay-for-api-agent-build", + environment=codebuild.BuildEnvironment( + # ARM64 matches AgentCore Runtime's Graviton hosts. + build_image=codebuild.LinuxArmBuildImage.AMAZON_LINUX_2_STANDARD_3_0, + compute_type=codebuild.ComputeType.SMALL, + privileged=True, # docker-in-docker for image build + ), + source=codebuild.Source.s3( + bucket=agent_source.bucket, + path=agent_source.s3_object_key, + ), + environment_variables={ + "AWS_ACCOUNT_ID": codebuild.BuildEnvironmentVariable( + value=self.account + ), + "AWS_DEFAULT_REGION": codebuild.BuildEnvironmentVariable( + value=self.region + ), + "ECR_REPO_URI": codebuild.BuildEnvironmentVariable( + value=agent_repo.repository_uri + ), + "IMAGE_TAG": codebuild.BuildEnvironmentVariable( + value=agent_source.asset_hash + ), + }, + build_spec=codebuild.BuildSpec.from_object( + { + "version": "0.2", + "phases": { + "pre_build": { + "commands": [ + "echo Logging in to ECR...", + "aws ecr get-login-password --region $AWS_DEFAULT_REGION | " + "docker login --username AWS --password-stdin " + "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com", + ], + }, + "build": { + "commands": [ + "echo Building agent image...", + "docker build -t $ECR_REPO_URI:$IMAGE_TAG .", + ], + }, + "post_build": { + "commands": [ + "echo Pushing to ECR...", + "docker push $ECR_REPO_URI:$IMAGE_TAG", + "docker tag $ECR_REPO_URI:$IMAGE_TAG $ECR_REPO_URI:latest", + "docker push $ECR_REPO_URI:latest", + ], + }, + }, + } + ), + ) + agent_repo.grant_pull_push(build_project) + + # ── Custom resource: kick off the build and wait for it to finish ── + # The Runtime resource below references the image URI — we need the + # image in ECR before CloudFormation moves past this step. + build_trigger_role = iam.Role( + self, + "BuildTriggerRole", + assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name( + "service-role/AWSLambdaBasicExecutionRole" + ), + ], + ) + build_trigger_role.add_to_policy( + iam.PolicyStatement( + actions=["codebuild:StartBuild", "codebuild:BatchGetBuilds"], + resources=[build_project.project_arn], + ) + ) + + build_trigger_fn = aws_lambda.Function( + self, + "BuildTriggerFn", + function_name="pay-for-api-agent-build-trigger", + runtime=aws_lambda.Runtime.PYTHON_3_12, + handler="index.handler", + role=build_trigger_role, + timeout=Duration.minutes(15), + memory_size=128, + code=aws_lambda.Code.from_inline( + r""" +import json +import time +import urllib.request + +import boto3 + + +def handler(event, context): + props = event.get("ResourceProperties", {}) + project_name = props.get("ProjectName", "") + + # No rebuild on stack delete — ECR contents are torn down by the + # repository's lifecycle. + if event["RequestType"] == "Delete": + return _respond(event, context, "SUCCESS", {"ImageBuilt": "skipped"}) + + cb = boto3.client("codebuild") + try: + build = cb.start_build(projectName=project_name) + build_id = build["build"]["id"] + print(f"Started CodeBuild: {build_id}") + + # Poll every 30 seconds for up to ~14 minutes. + for _ in range(28): + time.sleep(30) + result = cb.batch_get_builds(ids=[build_id]) + status = result["builds"][0]["buildStatus"] + print(f"Build status: {status}") + if status == "SUCCEEDED": + return _respond(event, context, "SUCCESS", {"BuildId": build_id}) + if status in ("FAILED", "FAULT", "STOPPED", "TIMED_OUT"): + return _respond( + event, context, "FAILED", + {"Error": f"CodeBuild {status}"}, + ) + return _respond(event, context, "FAILED", {"Error": "Build timed out"}) + except Exception as exc: # noqa: BLE001 + print(f"Error: {exc}") + return _respond(event, context, "FAILED", {"Error": str(exc)}) + + +def _respond(event, context, status, data): + body = json.dumps({ + "Status": status, + "Reason": json.dumps(data), + "PhysicalResourceId": context.log_stream_name, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "Data": data, + }) + req = urllib.request.Request( + event["ResponseURL"], + data=body.encode(), + method="PUT", + headers={"Content-Type": ""}, + ) + urllib.request.urlopen(req) +""" + ), + ) + + trigger_build = CustomResource( + self, + "TriggerImageBuild", + service_token=build_trigger_fn.function_arn, + properties={ + "ProjectName": build_project.project_name, + # Tie the CR hash to the asset hash — any change in + # agent/container/ triggers a rebuild automatically. + "SourceHash": agent_source.asset_hash, + }, + ) + + # ── IAM: runtime execution role ── + execution_role = iam.Role( + self, + "AgentExecutionRole", + assumed_by=iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"), + description=( + "Pay for API buyer agent runtime execution role. " + "Grants Bedrock model invoke + AgentCore Payments DP ops the " + "AgentCorePaymentsPlugin needs at runtime." + ), + ) + + # Bedrock model invoke — Claude Sonnet 4.5 via the cross-region US + # inference profile. Both the foundation model ARN and the + # inference-profile ARN are granted because Bedrock resolves + # through the profile. + execution_role.add_to_policy( + iam.PolicyStatement( + actions=[ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ], + resources=[ + # Inference profile (cross-region routing) + f"arn:aws:bedrock:{self.region}:{self.account}:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0", + # Underlying foundation model in each US region the profile + # can route to. + "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0", + "arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0", + "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0", + ], + ) + ) + + # AgentCore Payments data-plane operations the plugin calls at + # runtime. The Manager / Instrument / Session IDs are not known + # at role creation time (the notebook creates them in §4), so + # the resource list is wildcarded to all PaymentManagers in the + # caller's account. Production hardening: scope to the specific + # Manager ARN once it is stable, or add a tag-based condition + # on `aws:ResourceTag/Project`. + execution_role.add_to_policy( + iam.PolicyStatement( + actions=[ + "bedrock-agentcore:ProcessPayment", + "bedrock-agentcore:GetPaymentSession", + "bedrock-agentcore:GetPaymentInstrument", + "bedrock-agentcore:GetPaymentInstrumentBalance", + "bedrock-agentcore:GetResourcePaymentToken", + ], + resources=[ + f"arn:aws:bedrock-agentcore:{self.region}:{self.account}:payment-manager/*", + f"arn:aws:bedrock-agentcore:{self.region}:{self.account}:payment-manager/*/instrument/*", + f"arn:aws:bedrock-agentcore:{self.region}:{self.account}:payment-manager/*/session/*", + ], + ) + ) + + # CloudWatch Logs — Runtime expects the role to be able to write its + # own log stream. + execution_role.add_to_policy( + iam.PolicyStatement( + actions=[ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + ], + resources=[ + f"arn:aws:logs:{self.region}:{self.account}:log-group:/aws/bedrock-agentcore/*", + ], + ) + ) + + # ── Observability ── + # The agent container runs AWS Distro for OpenTelemetry and also + # wires up CloudWatch Logs vended delivery for the PaymentManager + # on first invocation (see `_ensure_vended_log_delivery` in + # agent.py). Both paths need the permissions below. + + # Logs vended-delivery pipeline: Payments → CloudWatch Logs. + # The delivery source/destination/delivery objects are not + # resource-scoped (CloudWatch Logs creates them per-region per- + # account), so the resource list stays wildcarded. The log + # group writes themselves are scoped to the agentcore-payments + # log group prefix. + execution_role.add_to_policy( + iam.PolicyStatement( + sid="CloudWatchLogsVendedDelivery", + actions=[ + "logs:CreateDelivery", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DeleteDelivery", + "logs:DeleteDeliveryDestination", + "logs:DeleteDeliverySource", + "logs:DeleteLogGroup", + "logs:DeleteResourcePolicy", + "logs:DescribeLogGroups", + "logs:DescribeResourcePolicies", + "logs:GetDelivery", + "logs:GetDeliveryDestination", + "logs:GetDeliverySource", + "logs:PutDeliveryDestination", + "logs:PutDeliverySource", + "logs:PutLogEvents", + "logs:PutResourcePolicy", + "logs:PutRetentionPolicy", + ], + # CloudWatch Logs does not permit resource-level scoping + # on Describe* and Put*Delivery* APIs. The log group + # actions are implicitly scoped by the delivery target, + # which we restrict via DeliveryDestination. Production + # hardening: scope to specific log group prefixes once + # stable. + resources=["*"], + ) + ) + + # X-Ray + CloudWatch Application Signals — ADOT emit targets. + # X-Ray and Application Signals do not accept resource-level + # ARNs on these actions; the documented IAM policy for ADOT + # observability uses Resource: "*". The agent's traces are + # implicitly scoped to its own session via OpenTelemetry + # context, not via IAM. + execution_role.add_to_policy( + iam.PolicyStatement( + sid="XRayApplicationSignalsCloudTrail", + actions=[ + "xray:GetTraceSegmentDestination", + "xray:ListResourcePolicies", + "xray:PutResourcePolicy", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments", + "xray:UpdateTraceSegmentDestination", + "application-signals:StartDiscovery", + "cloudtrail:CreateServiceLinkedChannel", + ], + resources=["*"], + ) + ) + + # Service-linked role for Application Signals — created once per + # account, condition-scoped to that specific SLR. + execution_role.add_to_policy( + iam.PolicyStatement( + sid="CreateServiceLinkedRoleForAppSignals", + actions=["iam:CreateServiceLinkedRole"], + resources=[ + "arn:*:iam::*:role/aws-service-role/" + "application-signals.cloudwatch.amazonaws.com/" + "AWSServiceRoleForCloudWatchApplicationSignals", + ], + ) + ) + + # PaymentsAllowVendedLogDeliveryForResource + + # AllowVendedLogDeliveryForResource on the PaymentManager — + # what lets Payments emit logs through the vended pipeline + # above. CloudWatch checks both actions implicitly when + # `logs.put_delivery_source` runs against a Payment Manager + # ARN: the Payments-prefixed one as the product-level gate, the + # unprefixed one as the AgentCore-wide gate. Scoped to + # PaymentManager resources only. + execution_role.add_to_policy( + iam.PolicyStatement( + sid="BedrockAgentCorePaymentsVendedLogDelivery", + actions=[ + "bedrock-agentcore:PaymentsAllowVendedLogDeliveryForResource", + "bedrock-agentcore:AllowVendedLogDeliveryForResource", + ], + resources=[ + f"arn:aws:bedrock-agentcore:{self.region}:{self.account}:payment-manager/*", + ], + ) + ) + + # ECR pull — the runtime pulls the image we built above. + agent_repo.grant_pull(execution_role) + + # Allow this role to be passed to bedrock-agentcore.amazonaws.com. + execution_role.add_to_policy( + iam.PolicyStatement( + actions=["iam:PassRole"], + resources=[execution_role.role_arn], + conditions={ + "StringEquals": { + "iam:PassedToService": "bedrock-agentcore.amazonaws.com" + } + }, + ) + ) + + # ── AgentCore Memory ── + # Persistent conversation memory for the buyer agent. Short + # event expiry because the demo is stateless between notebook + # runs; bump to 30+ days for real workloads. + agent_memory = bedrockagentcore.CfnMemory( + self, + "AgentMemory", + name="pay_for_api_agent_memory", + description=( + "Conversation memory for the Pay for API buyer agent. " + "Each invocation gets its own session under the caller's " + "paymentUserId actor." + ), + event_expiry_duration=7, + ) + + # Grant runtime role the memory CRUD actions it needs at invoke + # time. Scoped to the Memory resource we just created. + execution_role.add_to_policy( + iam.PolicyStatement( + sid="AgentCoreMemoryCRUD", + actions=[ + "bedrock-agentcore:CreateMemory", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:UpdateMemory", + "bedrock-agentcore:DeleteMemory", + "bedrock-agentcore:CreateMemoryRecord", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:UpdateMemoryRecord", + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:SearchMemoryRecords", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListSessions", + ], + resources=[ + f"arn:aws:bedrock-agentcore:{self.region}:{self.account}:memory/*", + ], + ) + ) + + # ── AgentCore Runtime ── + # containerUri points at the image we built in CodeBuild. The + # asset_hash is used as the tag so a change in agent/container/ + # cycles a new image + triggers Runtime update. + # + # networkMode=PUBLIC: the runtime container has outbound + # internet access, which the agent uses to call the seller's + # HTTP API. For production deployments that integrate with + # private services, switch to VPC mode and route the runtime + # through a NAT Gateway with VPC endpoints for AWS APIs. + runtime = bedrockagentcore.CfnRuntime( + self, + "AgentRuntime", + agent_runtime_name="pay_for_api_agent_runtime", + description=( + "Pay for API buyer agent — Strands Agent with Claude Sonnet " + "4.5 and AgentCorePaymentsPlugin for autonomous x402 payment." + ), + role_arn=execution_role.role_arn, + network_configuration={"networkMode": "PUBLIC"}, + protocol_configuration="HTTP", + agent_runtime_artifact={ + "containerConfiguration": { + "containerUri": f"{agent_repo.repository_uri}:{agent_source.asset_hash}", + }, + }, + environment_variables={ + "AWS_REGION": self.region, + "MODEL_ID": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "ENABLE_PAYMENTS_PLUGIN": "1", + # Turn on the vended log delivery wiring in agent.py on + # first invocation. Set to "0" for debugging. + "ENABLE_VENDED_LOG_DELIVERY": "1", + # AgentCore Memory resource the agent attaches to via + # AgentCoreMemorySessionManager in agent.py. + "BEDROCK_AGENTCORE_MEMORY_ID": agent_memory.attr_memory_id, + # ADOT auto-instrumentation (matches the defaults in + # agent.py so opentelemetry-instrument picks them up too). + "AGENT_OBSERVABILITY_ENABLED": "true", + "OTEL_PYTHON_DISTRO": "aws_distro", + "OTEL_PYTHON_CONFIGURATOR": "aws_configurator", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", + "OTEL_TRACES_EXPORTER": "otlp", + "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_METRICS_EXPORTER": "none", + }, + ) + + # Runtime must wait on the CodeBuild-built image being ready. + runtime.node.add_dependency(trigger_build) + # And on the memory resource being created so the env var is resolvable. + runtime.node.add_dependency(agent_memory) + + # ── Outputs ── + CfnOutput( + self, + "AgentRuntimeArn", + value=runtime.attr_agent_runtime_arn, + description="ARN of the deployed AgentCore Runtime", + ) + CfnOutput( + self, + "AgentRuntimeId", + value=runtime.attr_agent_runtime_id, + description="ID of the deployed AgentCore Runtime", + ) + CfnOutput( + self, + "AgentRuntimeEndpoint", + # Resolved at deploy time: the {region} and {runtime_id} + # placeholders are substituted into the AgentCore endpoint + # template by the CDK f-string before CloudFormation sees + # the value. + value=( + f"https://bedrock-agentcore.{self.region}.amazonaws.com/" + f"runtimes/{runtime.attr_agent_runtime_id}/invocations" + ), + description="Invoke URL for the deployed Runtime", + ) + CfnOutput( + self, + "AgentExecutionRoleArn", + value=execution_role.role_arn, + description="IAM role the Runtime assumes at invoke time", + ) + CfnOutput( + self, + "AgentEcrRepoUri", + value=agent_repo.repository_uri, + description="ECR repository URI the Runtime pulls from", + ) + CfnOutput( + self, + "AgentBuildProjectName", + value=build_project.project_name, + description="CodeBuild project that builds the agent image", + ) + CfnOutput( + self, + "AgentMemoryId", + value=agent_memory.attr_memory_id, + description="AgentCore Memory resource the runtime uses for sessions", + ) diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/app.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/app.py new file mode 100644 index 000000000..00a03279f --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/app.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""CDK app entry point for the Pay for API buyer agent runtime.""" + +import os + +import aws_cdk as cdk + +from agent_stack import AgentCorePaymentsBuyerAgentStack + +app = cdk.App() + +env = cdk.Environment( + account=os.environ.get("CDK_DEFAULT_ACCOUNT"), + region=os.environ.get( + "CDK_DEFAULT_REGION", + os.environ.get("AWS_REGION", "us-west-2"), + ), +) + +AgentCorePaymentsBuyerAgentStack( + app, + "AgentCorePaymentsBuyerAgentStack", + env=env, + description=( + "AgentCore Payments sample — Pay for API buyer agent (Strands Agent + " + "AgentCorePaymentsPlugin, deployed to AgentCore Runtime)" + ), +) + +app.synth() diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/cdk.json b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/cdk.json new file mode 100644 index 000000000..5c5b2aeae --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/cdk.json @@ -0,0 +1,18 @@ +{ + "app": "python3 app.py", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeVersionProps": true + } +} diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/requirements.txt b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/requirements.txt new file mode 100644 index 000000000..ae4eafb93 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/cdk/requirements.txt @@ -0,0 +1,2 @@ +aws-cdk-lib>=2.140.0 +constructs>=10.3.0,<11.0.0 diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/Dockerfile b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/Dockerfile new file mode 100644 index 000000000..b495d419e --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/Dockerfile @@ -0,0 +1,34 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +# curl ships in slim only as a tiny binary — needed for HEALTHCHECK. +# Keep apt cache wipe inline so the layer stays small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir -r requirements.txt + +# Agent source +COPY agent.py . + +# Non-root user +RUN useradd -m -r agent && chown -R agent:agent /app +USER agent + +EXPOSE 8080 + +# AgentCore Runtime requires GET /ping returning 200; we hit the same +# endpoint here so docker / orchestrators can detect a stuck process. +# The URL is loopback-only (the request never leaves the container) +# so HTTP without TLS is appropriate and intentional — there's no +# network path for an attacker to intercept it. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/ping || exit 1 + +# Wrap with opentelemetry-instrument so ADOT's auto-instrumentation +# attaches to uvicorn + boto3 at process start. The in-process +# `_load_instrumentors()` fallback in agent.py covers the case where +# this wrapper is not used (e.g. local dev). +CMD ["opentelemetry-instrument", "python", "agent.py"] diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/agent.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/agent.py new file mode 100644 index 000000000..16b675651 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/agent.py @@ -0,0 +1,487 @@ +"""Pay for API — AgentCore Runtime buyer agent. + +A minimal Strands Agent, wrapped in a FastAPI ``/invocations`` endpoint so it +conforms to the AgentCore Runtime contract. The agent has exactly one tool — +``http_request`` from ``strands-agents-tools`` — and relies on the +``AgentCorePaymentsPlugin`` (from ``bedrock-agentcore``) to transparently +handle HTTP 402 → ``ProcessPayment`` → retry. + +No private keys. No manual x402 assembly. The caller supplies the payment +context (manager ARN, instrument ID, session ID, vendor-level user ID) on +every invocation, mirroring the pattern in ``agentcore-payments/payment-agent``. + +Runtime invocation contract: + + POST /invocations + { + "prompt": "Tell me one fact about space", + "sellerUrl": "https://example.com/", + "managerArn": "arn:aws:bedrock-agentcore:…:payment-manager/…", + "instrumentId": "payment-instrument-…", + "sessionId": "payment-session-…", + "paymentUserId": "", + "region": "us-west-2" # optional, defaults to AWS_REGION + } + +Health endpoint: + + GET /ping → {"status": "ok"} +""" + +from __future__ import annotations + +# ── ADOT auto-instrumentation (must run before any other imports) ── +# These env vars tell AWS Distro for OpenTelemetry how to export traces +# and logs to CloudWatch via the ADOT collector that AgentCore Runtime +# injects into the container. Setting them at the top of the module is +# required because some OTEL libraries read env at import time. +import os + +os.environ.setdefault("AGENT_OBSERVABILITY_ENABLED", "true") +os.environ.setdefault("OTEL_PYTHON_DISTRO", "aws_distro") +os.environ.setdefault("OTEL_PYTHON_CONFIGURATOR", "aws_configurator") +os.environ.setdefault("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") +os.environ.setdefault("OTEL_TRACES_EXPORTER", "otlp") +os.environ.setdefault("OTEL_LOGS_EXPORTER", "otlp") +# Metrics disabled — traces + logs cover the observability surface we care +# about (payment calls, tool use, HTTP requests). Enable if you need them. +os.environ.setdefault("OTEL_METRICS_EXPORTER", "none") + +try: + from opentelemetry.instrumentation.auto_instrumentation._load import ( + _load_configurators, + _load_distro, + _load_instrumentors, + ) + + _distro = _load_distro() + _distro.configure() + _load_configurators() + _load_instrumentors(_distro) +except Exception as _otel_err: # noqa: BLE001 — ADOT optional for local dev + import sys + + print(f"[WARN] ADOT auto-instrumentation skipped: {_otel_err}", file=sys.stderr) + +# ── Standard imports ── +import logging + +import boto3 +import botocore.exceptions +import fastapi +import uvicorn +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", +) +logger = logging.getLogger("pay-for-api-agent") + +AWS_REGION = os.environ.get("AWS_REGION", "us-west-2") +# Claude Sonnet 4.5 cross-region inference profile (US). +MODEL_ID = os.environ.get( + "MODEL_ID", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", +) + +# AgentCore Memory — if set, every invocation threads through an +# AgentCoreMemorySessionManager keyed on (memory_id, actor_id=paymentUserId, +# session_id=per-invocation). The CDK stack sets this in the container's +# environment; if the variable is missing the agent runs without memory. +MEMORY_ID = os.environ.get("BEDROCK_AGENTCORE_MEMORY_ID", "") + +# AgentCorePaymentsPlugin gate. Defaults to enabled in the container — the +# runtime is isolated and the notebook is driving the invocation. Flip to +# "0" / "false" to fall back to a no-payments agent for debugging. +ENABLE_PAYMENTS_PLUGIN = os.environ.get("ENABLE_PAYMENTS_PLUGIN", "1").lower() in ( + "1", + "true", + "yes", +) + +# AgentCore Payments vended-log delivery gate. When enabled and a +# `managerArn` is supplied on the first invocation, the agent configures +# CloudWatch Logs vended delivery for that Manager. Idempotent — re-runs +# are no-ops. Defaults to enabled. +ENABLE_VENDED_LOG_DELIVERY = os.environ.get( + "ENABLE_VENDED_LOG_DELIVERY", "1" +).lower() in ("1", "true", "yes") + +# Track Manager ARNs we have already configured vended delivery for, so +# the agent doesn't re-call the control-plane on every invocation. +_VENDED_LOG_DELIVERY_CONFIGURED: set[str] = set() + +SYSTEM_PROMPT = ( + "You are a research agent powered by Amazon Bedrock AgentCore Payments.\n" + "\n" + "Your only tool is `http_request`. Use it to fetch paid facts from the\n" + "Fun Facts API. Each `GET` returns exactly one fact and costs $0.01 in\n" + "USDC. The AgentCore Payments plugin pays on your behalf — you never\n" + "handle private keys, assemble payment headers, or retry failed calls.\n" + "\n" + "SELLER CONTRACT\n" + " Endpoint: GET /facts?topic=\n" + " Supported topics: space, oceans, ai, payments\n" + " (any other value falls back to a random general fact)\n" + ' Success body: {"x402_content": {"data": "", ...},\n' + ' "x402_meta": {"seller": ..., "generated_at": ...}}\n' + " `x402_content.data` is a JSON string — parse it to\n" + ' read `{"topic": ..., "fact": ...}`.\n' + " Price per call: $0.01 USDC.\n" + "\n" + "RULES\n" + " 1. One `http_request` GET per topic the user asks about.\n" + " If the user asks for two topics, make two calls.\n" + " 2. If the user's topic is not in the supported list, pick the closest\n" + " supported topic rather than letting the seller fall back silently —\n" + " e.g. 'volcanoes' → 'space', 'whales' → 'oceans'.\n" + " 3. Parse `x402_content.data` to get the `fact` and answer concisely,\n" + " citing each fact verbatim.\n" + " 4. End every response with the total amount spent in USD — $0.01 per\n" + " successful call.\n" +) + + +def _ensure_vended_log_delivery(manager_arn: str, region: str) -> None: + """Idempotently wire CloudWatch Logs vended delivery for a PaymentManager. + + Three control-plane ops, each a no-op on re-run: + + 1. ``CreateLogGroup`` — destination Log Group, if missing. + 2. ``PutDeliverySource`` — Payments → logs pipe. + 3. ``PutDeliveryDestination`` — target the Log Group. + 4. ``CreateDelivery`` — bind source to destination. + + Authorization for the Manager to vend logs is granted by the IAM + permissions + ``bedrock-agentcore:PaymentsAllowVendedLogDeliveryForResource`` and + ``bedrock-agentcore:AllowVendedLogDeliveryForResource`` on the + calling principal (already attached to the agent runtime's + execution role in the CDK stack — CloudWatch checks both as a + product-level + service-level gate). There is no SDK call to "arm" + vended delivery; CloudWatch authorizes both implicitly when + ``put_delivery_source`` runs against a Payment Manager ARN. + See ``docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AWS-logs-infrastructure-V2-service-specific.html``. + + Any ``ConflictException`` / already-exists shape is swallowed so this + can run on every Manager the agent sees without side effects. + """ + if not ENABLE_VENDED_LOG_DELIVERY or not manager_arn: + return + if manager_arn in _VENDED_LOG_DELIVERY_CONFIGURED: + return + + # Derive a stable, Manager-scoped log group name from the Manager ID so + # re-runs of the same Manager hit the same log group instead of creating + # duplicates. The Manager ID is the last path segment of the ARN. + manager_id = manager_arn.rsplit("/", 1)[-1] + log_group_name = f"/bedrock-agentcore/payments/{manager_id}" + source_name = f"pay-for-api-payments-src-{manager_id}" + destination_name = f"pay-for-api-payments-dest-{manager_id}" + + logs_client = boto3.client("logs", region_name=region) + + # STS account lookup so we can construct the destination ARN below. + account_id = boto3.client("sts", region_name=region).get_caller_identity()[ + "Account" + ] + destination_arn = ( + f"arn:aws:logs:{region}:{account_id}:delivery-destination:{destination_name}" + ) + log_group_arn = f"arn:aws:logs:{region}:{account_id}:log-group:{log_group_name}" + + def _swallow(code_set: set[str], fn, **kwargs): + """Call fn(**kwargs); swallow the given error codes.""" + try: + return fn(**kwargs) + except botocore.exceptions.ClientError as exc: + err_code = exc.response["Error"].get("Code", "") + if err_code in code_set: + return None + raise + + # 1. Ensure the log group exists before we point a delivery at it. + _swallow( + {"ResourceAlreadyExistsException"}, + logs_client.create_log_group, + logGroupName=log_group_name, + ) + + # 2. Delivery source — Payments resource emits APPLICATION_LOGS. + # CloudWatch validates the caller's + # bedrock-agentcore:PaymentsAllowVendedLogDeliveryForResource and + # bedrock-agentcore:AllowVendedLogDeliveryForResource permissions + # against the resourceArn at this point. Without either, this call + # returns AccessDeniedException. + _swallow( + {"ConflictException", "ResourceAlreadyExistsException"}, + logs_client.put_delivery_source, + name=source_name, + resourceArn=manager_arn, + logType="APPLICATION_LOGS", + ) + + # 3. Delivery destination — Log Group we just ensured. + _swallow( + {"ConflictException", "ResourceAlreadyExistsException"}, + logs_client.put_delivery_destination, + name=destination_name, + deliveryDestinationConfiguration={ + "destinationResourceArn": log_group_arn, + }, + ) + + # 4. Bind source to destination. CreateDelivery is idempotent on the + # (source, destination) pair — returns ConflictException on re-runs. + _swallow( + {"ConflictException", "ResourceAlreadyExistsException"}, + logs_client.create_delivery, + deliverySourceName=source_name, + deliveryDestinationArn=destination_arn, + ) + + _VENDED_LOG_DELIVERY_CONFIGURED.add(manager_arn) + logger.info( + "Vended log delivery ensured for Manager %s → %s", + manager_id, + log_group_name, + ) + + +def _build_agent(payment_config: dict | None): + """Construct a Strands Agent with one http_request tool and — if payment + context is provided — the AgentCorePaymentsPlugin for automatic x402 + handling. + + ``payment_config`` keys: + - manager_arn, instrument_id, session_id, payment_user_id, region + """ + from strands import Agent + from strands.models.bedrock import BedrockModel + from strands_tools import http_request + + model = BedrockModel( + model_id=MODEL_ID, + region_name=AWS_REGION, + temperature=0.7, + ) + + # ── AgentCoreMemorySessionManager ── + # Memory is keyed on (memory_id, actor_id, session_id). We use the + # vendor-assigned paymentUserId as actor so all of a user's + # invocations roll up under one actor regardless of which notebook + # kernel or process is driving the runtime. If memory is unavailable + # (bad SDK version, missing resource, etc.) we log and continue + # without — the plugin still works. + session_manager = None + actor_id = (payment_config or {}).get("payment_user_id") or "" + if MEMORY_ID and actor_id: + try: + import uuid as _uuid + + from bedrock_agentcore.memory.integrations.strands.config import ( + AgentCoreMemoryConfig, + ) + from bedrock_agentcore.memory.integrations.strands.session_manager import ( + AgentCoreMemorySessionManager, + ) + + session_id = f"{actor_id}-{_uuid.uuid4().hex[:8]}" + memory_config = AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, + ) + session_manager = AgentCoreMemorySessionManager( + agentcore_memory_config=memory_config, + region_name=AWS_REGION, + ) + logger.info( + "AgentCoreMemorySessionManager attached memory=%s actor=%s session=%s", + MEMORY_ID, + actor_id, + session_id, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Memory session manager unavailable, continuing without: %s", + exc, + ) + + plugins: list = [] + if ENABLE_PAYMENTS_PLUGIN and payment_config: + missing = [ + k + for k in ("manager_arn", "instrument_id", "session_id", "payment_user_id") + if not payment_config.get(k) + ] + if missing: + logger.info( + "AgentCorePaymentsPlugin skipped — missing fields on this " + "invocation: %s", + missing, + ) + else: + try: + from bedrock_agentcore.payments.integrations.config import ( + AgentCorePaymentsPluginConfig, + ) + from bedrock_agentcore.payments.integrations.strands.plugin import ( + AgentCorePaymentsPlugin, + ) + + plugin_cfg = AgentCorePaymentsPluginConfig( + payment_manager_arn=payment_config["manager_arn"], + user_id=payment_config["payment_user_id"], + payment_instrument_id=payment_config["instrument_id"], + payment_session_id=payment_config["session_id"], + region=payment_config.get("region") or AWS_REGION, + agent_name="pay-for-api-agent", + network_preferences_config=payment_config.get( + "network_preferences" + ), + ) + plugins.append(AgentCorePaymentsPlugin(config=plugin_cfg)) + logger.info( + "AgentCorePaymentsPlugin attached — manager=%s instrument=%s " + "session=%s user=%s", + payment_config["manager_arn"], + payment_config["instrument_id"], + payment_config["session_id"], + payment_config["payment_user_id"], + ) + except Exception as exc: # noqa: BLE001 — plugin optional at edit time + logger.warning( + "AgentCorePaymentsPlugin init failed, continuing without: %s", + exc, + ) + + kwargs: dict = { + "model": model, + "tools": [http_request], + "system_prompt": SYSTEM_PROMPT, + } + if plugins: + kwargs["plugins"] = plugins + if session_manager is not None: + kwargs["session_manager"] = session_manager + + # Wrap agent construction in a try/retry so a corrupt memory session + # doesn't break the invocation. On failure we drop memory and retry + # with a fresh agent — the plugin still pays. + try: + return Agent(**kwargs) + except Exception as exc: # noqa: BLE001 + if session_manager is None: + raise + logger.warning( + "Agent init with memory failed (%s) — retrying without memory", + exc, + ) + kwargs.pop("session_manager", None) + return Agent(**kwargs) + + +# ── FastAPI app ── + +app = FastAPI(title="Pay for API — Buyer Agent", version="1.0.0") + + +@app.get("/ping") +async def ping(): + return JSONResponse(content={"status": "ok"}, status_code=200) + + +# Maximum prompt length the /invocations endpoint accepts. Bounds the +# Bedrock token bill on each invoke and prevents a flood of multi-MB +# prompts from filling the runtime memory. Tune up if your use case +# legitimately needs longer prompts; this is a defensive cap, not a +# product constraint. +MAX_PROMPT_LEN = 5000 + + +@app.post("/invocations") +async def invocations(request: fastapi.Request): + try: + data = await request.json() + except Exception as exc: # noqa: BLE001 + logger.warning("Invalid JSON body on /invocations: %s", exc) + return JSONResponse( + content={"error": "Invalid JSON body"}, + status_code=400, + ) + + prompt = data.get("prompt") or data.get("text") or data.get("message") or "" + seller_url = data.get("sellerUrl") or data.get("seller_url") + if not prompt: + return JSONResponse(content={"error": "prompt is required"}, status_code=400) + if not seller_url: + return JSONResponse(content={"error": "sellerUrl is required"}, status_code=400) + + # ── Defensive input validation ── + # The prompt is forwarded to Bedrock and the seller URL is fetched + # by the http_request tool. Bounding both keeps the runtime + # behaving on hostile input even though AgentCore Runtime fronts + # this endpoint with its own auth + payload validation. + if len(prompt) > MAX_PROMPT_LEN: + return JSONResponse( + content={"error": f"prompt exceeds {MAX_PROMPT_LEN} characters"}, + status_code=400, + ) + if not (seller_url.startswith("https://") or seller_url.startswith("http://")): + return JSONResponse( + content={"error": "sellerUrl must be an http(s) URL"}, + status_code=400, + ) + + payment_config = { + "manager_arn": data.get("managerArn") or data.get("manager_arn", ""), + "instrument_id": data.get("instrumentId") or data.get("instrument_id", ""), + "session_id": data.get("sessionId") or data.get("session_id", ""), + "payment_user_id": data.get("paymentUserId") or data.get("payment_user_id", ""), + "region": data.get("region", AWS_REGION), + "network_preferences": ( + data.get("networkPreferences") or data.get("network_preferences") + ), + } + + # Wire up vended log delivery the first time we see a Manager — no-op + # thereafter for the same Manager in the same process. Any errors are + # logged but do not fail the invocation, since observability is a + # best-effort add-on. + try: + _ensure_vended_log_delivery( + manager_arn=payment_config["manager_arn"], + region=payment_config["region"], + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Vended log delivery setup failed, continuing: %s", exc) + + # We prepend the seller URL to the user prompt so the agent knows the + # exact URL to GET. Keeping it out of the system prompt lets the + # notebook point the same agent at different sellers without rebuild. + enriched_prompt = f"Seller URL: {seller_url.rstrip('/')}/facts\n\n{prompt}" + + try: + agent = _build_agent(payment_config=payment_config) + result = agent(enriched_prompt) + return JSONResponse(content={"response": str(result)}) + except Exception as exc: # noqa: BLE001 + logger.error("Invocation error: %s", exc, exc_info=True) + return JSONResponse( + content={"error": "Agent invocation failed. See runtime logs for details."}, + status_code=500, + ) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "8080")) + # AgentCore Runtime routes traffic into the container on all + # interfaces, so bind to 0.0.0.0 inside the container by default. + # Override with HOST=127.0.0.1 when running the container directly on + # a developer machine. + host = os.environ.get("HOST", "0.0.0.0") # nosec B104 — required by AgentCore Runtime + logger.info("Starting pay-for-api agent on %s:%s", host, port) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/requirements.txt b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/requirements.txt new file mode 100644 index 000000000..0e8d422ce --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/agent/container/requirements.txt @@ -0,0 +1,27 @@ +# Pay for API — buyer agent container dependencies. + +# Core web framework +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 + +# AWS SDK +# Note: AgentCore Payments observability uses the standard CloudWatch +# vended-logs pattern. There is no dedicated SDK call to "arm" a +# Manager — CloudWatch authorizes the +# bedrock-agentcore:AllowVendedLogDeliveryForResource IAM permission +# implicitly when the agent calls logs.put_delivery_source against the +# Manager ARN. Any boto3 release with the standard logs client works. +boto3>=1.42.0 +botocore>=1.42.0 + +# AgentCore SDK — provides AgentCorePaymentsPlugin +bedrock-agentcore>=1.9.0 + +# Strands Agents framework + http_request tool +strands-agents>=1.39.0 +strands-agents-tools>=0.5.0 + +# AWS Distro for OpenTelemetry — auto-instruments FastAPI + boto3 so +# traces and logs flow to CloudWatch via the ADOT collector that +# AgentCore Runtime injects into the container. +aws-opentelemetry-distro>=0.10.1 diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/env-sample.txt b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/env-sample.txt new file mode 100644 index 000000000..30242a4ce --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/env-sample.txt @@ -0,0 +1,147 @@ +# ══════════════════════════════════════════════════════════════════════ +# AgentCore Payments — Pay for API (Fun Facts) +# +# Copy this file to `.env` and fill in values. The notebook's §2 seeds +# `.env` from this template on first run and lists the keys you still +# need to fill in. +# +# Or copy manually and edit by hand: +# cp env-sample.txt .env +# +# Every env var the notebook, seller deploy script, and agent container +# read is here. Empty values are filled in as you go: +# * `setup-roles.sh` writes the four IAM role ARNs. +# * `deploy-seller.sh` writes `SELLER_API_URL`. +# * §4 of the notebook writes Manager, Connector, Credential Provider, +# and Payment Instrument IDs. +# * §5 writes the two Session IDs. +# +# ── Wallet provider secret handoff ── +# Wallet provider secrets (Coinbase / Privy keys, Privy authorization +# private key) are pasted into this file once. The notebook's §4 reads +# them to call `CreatePaymentCredentialProvider`, which hands them to +# AgentCore Identity. The service stores them in AWS Secrets Manager +# under AWS KMS encryption and surfaces only the secret ARN to your +# agent. The agent runtime calls `GetResourcePaymentToken` at signing +# time and never receives the raw secret. +# +# After §4 runs, the secrets in this file are no longer used at runtime. +# `.env` is `.gitignore`-d so it is never committed; you can blank the +# secret values once setup is complete and re-running §4 from a fresh +# .env will recreate the credential provider with whatever you paste. +# See "Configure credential provider" in the AgentCore Payments docs. +# ══════════════════════════════════════════════════════════════════════ + +# ── AWS ────────────────────────────────────────────────────────────── +# AgentCore Payments is available in preview in these regions: +# us-east-1 — US East (N. Virginia) +# us-west-2 — US West (Oregon) +# eu-central-1 — Europe (Frankfurt) +# ap-southeast-2 — Asia Pacific (Sydney) +# boto3 derives the control-plane and data-plane endpoints from the +# region, so no endpoint URLs are needed here. +AWS_REGION=us-west-2 +# AWS_PROFILE=default # Uncomment to use a named AWS CLI profile + +# ── IAM roles — created by test/integration/setup-roles.sh ─────────── +# setup-roles.sh creates four roles idempotently. The notebook assumes +# into each one for the operation that role is allowed to perform. +# Run it once before the notebook — it writes the four ARNs below +# back into this .env automatically: +# bash test/integration/setup-roles.sh +CONTROL_PLANE_ROLE_ARN=arn:aws:iam:::role/AgentCorePaymentsControlPlaneRole +MANAGEMENT_ROLE_ARN=arn:aws:iam:::role/AgentCorePaymentsManagementRole +PROCESS_PAYMENT_ROLE_ARN=arn:aws:iam:::role/AgentCorePaymentsProcessPaymentRole +RESOURCE_RETRIEVAL_ROLE_ARN=arn:aws:iam:::role/AgentCorePaymentsResourceRetrievalRole + +# ── Wallet provider credentials — both providers ───────────────────── +# The notebook wires up BOTH providers side-by-side under a single +# Manager. Fill in both sets. §4.2 creates the Credential Providers, +# §4.4 creates the Connectors, §4.5 creates two Instruments per +# provider (EVM + Solana = four wallets total). + +# Coinbase CDP — coinbase.com/developer-platform → Project → API Keys +# and Project → Wallet → Wallet Secret. Enable "Delegated signing" +# under Project → Wallet → Embedded Wallets → Policies before first use. +COINBASE_API_KEY_ID= +COINBASE_API_KEY_SECRET= +COINBASE_WALLET_SECRET= + +# Stripe-via-Privy — Privy dashboard → App → API Keys and +# App → Authorization Keys. The private key is returned prefixed with +# `wallet-auth:` — strip the prefix before pasting below. +PRIVY_APP_ID= +PRIVY_APP_SECRET= +PRIVY_AUTHORIZATION_ID= +PRIVY_AUTHORIZATION_PRIVATE_KEY= + +# ── AgentCore Payments state — populated by the notebook ──────────── +# Leave empty on first run. §4 creates one Manager, two Credential +# Providers (CDP + Privy), two Connectors, and four Instruments. +# §5 creates two Sessions (one per provider). +MANAGER_ID= +MANAGER_ARN= + +CRED_PROVIDER_NAME_CDP= +CREDENTIAL_PROVIDER_ARN_CDP= +CRED_PROVIDER_NAME_PRIVY= +CREDENTIAL_PROVIDER_ARN_PRIVY= + +CONNECTOR_ID_CDP= +CONNECTOR_ID_PRIVY= + +PAYMENT_INSTRUMENT_ID_CDP_EVM= +WALLET_ADDRESS_CDP_EVM= +PAYMENT_INSTRUMENT_ID_CDP_SOL= +WALLET_ADDRESS_CDP_SOL= +PAYMENT_INSTRUMENT_ID_PRIVY_EVM= +WALLET_ADDRESS_PRIVY_EVM= +PAYMENT_INSTRUMENT_ID_PRIVY_SOL= +WALLET_ADDRESS_PRIVY_SOL= + +SESSION_ID_CDP= +SESSION_ID_PRIVY= + +# ── Payment session tuning ─────────────────────────────────────────── +# Budget the operator authorises per session. USD amount. +SESSION_MAX_SPEND=1.00 +# 15–480 minutes; enforced by the service. +SESSION_EXPIRY_MINUTES=120 +# Informational — used as the `userId` header on CreatePaymentSession. +# Note: for hub-provisioned Coinbase instruments the service assigns its +# own CDP end-user UUID and ignores what we send; §6 reads the +# authoritative id back from `paymentInstrument.userId`. +USER_ID= + +# Email the embedded wallets are linked to. CreatePaymentInstrument +# requires `linkedAccounts[{email:{emailAddress}}]` — the service uses +# it to resolve or create the vendor-side end-user that owns the +# wallet. Prefer a real inbox you control so Coinbase Wallet Hub and +# Privy can send verification mail for signing-delegation grants. +# Format: a real email address you can read. Example: alex@example.com +INSTRUMENT_EMAIL= + +# ── Seller (Fun Facts API) ─────────────────────────────────────────── +# Populated by deploy-seller.sh after `cdk deploy` prints SellerApiUrl. +SELLER_API_URL= + +# Seller payout wallets. Used by deploy-seller.sh at seller deploy time — +# not by the notebook agent. Set at least the EVM wallet; Solana is +# optional but required for the §7 SOL run. +# EVM format: 0x-prefixed 40-character hex. +# Example: 0x1234567890abcdef1234567890abcdef12345678 +# Solana format: base58, 32-44 characters. +# Example: 5Yb8Zr9T3kQv2Xm4Np1Wq6Rs7Ht8Ju9Kv0Lp1Mq2Nr3 +SELLER_WALLET_ADDRESS= +SELLER_SOLANA_WALLET_ADDRESS= + +# Seller tuning (optional). Leave commented for the defaults. +# X402_FACILITATOR_URL=https://x402.org/facilitator +# X402_PRICE=$0.01 + +# ── Agent container (optional overrides) ───────────────────────────── +# Only consumed inside the AgentCore Runtime container in §8. Ignored +# elsewhere. The CDK stack already defaults these — override here only +# if you want a different model or want to disable the plugin at runtime. +# MODEL_ID=us.anthropic.claude-sonnet-4-5-20250929-v1:0 +# ENABLE_PAYMENTS_PLUGIN=1 diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_cloudwatch_genai_observability_dashboard.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_cloudwatch_genai_observability_dashboard.png new file mode 100644 index 000000000..5b68091e5 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_cloudwatch_genai_observability_dashboard.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_observability_results.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_observability_results.png new file mode 100644 index 000000000..ab506f221 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_observability_results.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_observability_section.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_observability_section.png new file mode 100644 index 000000000..84d9211d1 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_observability_section.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_selected.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_selected.png new file mode 100644 index 000000000..21284e897 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_runtime_selected.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_tracing_enable_section.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_tracing_enable_section.png new file mode 100644 index 000000000..b203a6998 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_tracing_enable_section.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_transaction_search_enabled.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_transaction_search_enabled.png new file mode 100644 index 000000000..b040962e4 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/agentcore_transaction_search_enabled.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/architecture_pay_for_api.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/architecture_pay_for_api.png new file mode 100644 index 000000000..03973039f Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/architecture_pay_for_api.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_delegation.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_delegation.png new file mode 100644 index 000000000..b2b3fdce2 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_delegation.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_otp.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_otp.png new file mode 100644 index 000000000..459c848b4 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_otp.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_signin.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_signin.png new file mode 100644 index 000000000..485b38a6d Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/cdp_hub_signin.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_give_access.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_give_access.png new file mode 100644 index 000000000..9f41f25de Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_give_access.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_landing.png b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_landing.png new file mode 100644 index 000000000..7d0424314 Binary files /dev/null and b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/images/privy_landing.png differ diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/pay-for-api.ipynb b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/pay-for-api.ipynb new file mode 100644 index 000000000..3617c6702 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/pay-for-api.ipynb @@ -0,0 +1,2980 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-00", + "metadata": {}, + "source": [ + "# Pay-For-API\n", + "\n", + "## Overview\n", + "\n", + "**Amazon Bedrock AgentCore Payments** enables AI agents to make autonomous payments\n", + "for digital services \u2014 without ever holding private keys or requiring human approval\n", + "for each transaction.\n", + "\n", + "In this use case this use case builds a Strands agent that buys metered access to a paid HTTP\n", + "API through AgentCore Payments. The seller is a minimal \"Fun Facts\" service deployed\n", + "via AWS CDK: an Amazon API Gateway HTTP API backed by an AWS Lambda function that charges **$0.01** per\n", + "call. When the agent requests a fact, the seller returns an HTTP 402 response (the [x402 protocol](https://x402.org), an open spec that revives the reserved 402 status code for machine-to-machine micropayments) with\n", + "a payment requirement. The agent forwards that requirement to AgentCore Payments'\n", + "`ProcessPayment` operation, receives a signed proof, retries the request with the\n", + "proof attached, and returns the paid fact \u2014 all without the agent ever touching a\n", + "private key.\n", + "\n", + "Internally, AgentCore Payments manages the wallet, the signing keys, and the\n", + "on-chain settlement for you. Whether your `PaymentManager` is wired to **Coinbase\n", + "CDP** or **Stripe via Privy**, the code the agent runs is identical \u2014 the service\n", + "picks the right signer from the connector tied to your instrument.\n", + "\n", + "### Use Case Details\n", + "\n", + "| Information | Details |\n", + "|:--------------------|:----------------------------------------------------------------------|\n", + "| Use case type | Agentic HTTP API consumption with autonomous micropayment |\n", + "| AgentCore components| Amazon Bedrock AgentCore Payments |\n", + "| Wallet providers | Coinbase CDP \u2705 \u00b7 Stripe via Privy \u2705 |\n", + "| Payment protocol | x402 (HTTP 402 Payment Required) on the wire |\n", + "| Agent type | Single |\n", + "| Agentic Framework | Strands Agents |\n", + "| LLM model | Anthropic Claude Sonnet 4.5 (Amazon Bedrock, `us.` inference profile) |\n", + "| Complexity | Intermediate |\n", + "| SDK used | boto3 |\n", + "\n", + "### Architecture\n", + "\n", + "Three roles participate in every paid request:\n", + "\n", + "1. **Strands agent** \u2014 calls `http_request`; the AgentCorePaymentsPlugin handles 402 \u2192 ProcessPayment \u2192 retry\n", + "2. **Amazon Bedrock AgentCore Payments** \u2014 receives `ProcessPayment` and returns a\n", + " signed proof using the wallet tied to your instrument (Coinbase CDP or Privy)\n", + "3. **Seller (CDK stack)** \u2014 Lambda behind API Gateway that issues the 402 challenge,\n", + " verifies the proof, and serves the content\n", + "\n", + "
\n", + " \"Pay-for-API\n", + "
\n", + "\n", + "**Numbered flow (matches the diagram)**\n", + "\n", + "1. **User** sends a query to the **Agent** (AgentCore Runtime + Strands).\n", + "2. The agent calls the paid API hosted on **Amazon API Gateway** \u2192 **AWS Lambda**.\n", + "3. The seller responds with **HTTP 402 Payment Required** and a payment requirement payload.\n", + "4. The agent forwards the requirement to **AgentCore Payments**, which selects the\n", + " matching `PaymentInstrument`, checks the session budget, and signs the payment\n", + " through the configured wallet provider (Coinbase CDP or Stripe via Privy).\n", + "5. The agent retries the request with the signed `X-PAYMENT` header. The seller\n", + " verifies, settles on-chain through the x402 facilitator, and returns **200 OK** with the content.\n", + "6. The agent answers the user. The operator audits spend through `GetPaymentSession`.\n", + "\n", + "### Use Case Key Features\n", + "\n", + "* By design, the agent does not hold private keys. AgentCore Payments signs every charge via the\n", + " configured `PaymentManager` and `PaymentConnector`\n", + "* Wallet-provider-agnostic \u2014 the exact same agent code runs against a Coinbase CDP\n", + " instrument or a Stripe-via-Privy instrument\n", + "* Human-controlled budget via `maxSpendAmount` on the payment session\n", + "* IAM role separation: `ManagementRole` creates sessions, `ProcessPaymentRole` signs\n", + " payments (explicit `Deny` in both directions \u2014 enforced, not documented)\n", + "* Full audit trail via `GetPaymentSession` \u2014 the operator sees exactly what the\n", + " agent spent\n", + "\n", + "### API Reference\n", + "\n", + "The AgentCore Payments APIs used in this use case are described in the service's\n", + "OpenAPI specs (bundled with the repo for convenience):\n", + "\n", + "| # | API | Plane | Purpose |\n", + "|---|-----|-------|---------|\n", + "| 1 | `CreatePaymentCredentialProvider` | Control | Store your wallet provider credentials securely |\n", + "| 2 | `CreatePaymentManager` | Control | Top-level payment processing resource |\n", + "| 3 | `CreatePaymentConnector` | Control | Bind a Credential Provider to a Manager |\n", + "| 4 | `CreatePaymentInstrument` | Data | Provision an embedded wallet under a Manager for a user |\n", + "| 5 | `GetPaymentInstrument` | Data | Read the service-assigned vendor `userId` back (\u00a77) |\n", + "| 6 | `CreatePaymentSession` | Data | Create a budget-limited session scoped to the instrument |\n", + "| 7 | `ProcessPayment` | Data | Generate a cryptographic proof for a single x402 charge |\n", + "| 8 | `GetPaymentSession` | Data | Read session state + `availableLimits.availableSpendAmount` |\n", + "| 9 | `GetPaymentInstrumentBalance` | Data | On-chain USDC balance for the instrument's wallet |\n", + "| 10 | `ListPaymentInstruments` | Data | All instruments under a manager (filter by user) |\n", + "| 11 | `ListPaymentSessions` | Data | All sessions under a manager (filter by user) |\n", + "| 12 | `DeletePaymentSession` | Data | Hard-delete a session \u2014 the revoke path |\n", + "| 13 | `DeletePaymentInstrument` | Data | Soft-delete an instrument (status flips to `DELETED`) |\n", + "\n", + "The boto3 service references for `bedrock-agentcore-control` (CP) and\n", + "`bedrock-agentcore` (DP) have the full operation list and response\n", + "shapes.\n", + "\n", + "For conceptual background and the full AgentCore Payments API surface,\n", + "see the public documentation:\n", + "\n", + "- [AgentCore Payments overview](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html)\n", + "- [How it works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html)\n", + "- [Core concepts](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-concepts.html)\n", + "- [Prerequisites](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-prerequisites.html)\n", + "- [IAM roles](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html)\n", + "- [Set up a credential provider](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-setup-credential-provider.html)\n", + "- [Create a payment manager](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-manager.html)\n", + "- [Create a payment instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html)\n", + "- [Create a payment session](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-session.html)\n", + "- [Process a payment](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-process-payment.html) \u2014 plugin reference, network preferences, interrupt contract\n", + "- [Connect to Bazaar](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-connect-bazaar.html)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-01", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "To run this notebook you will need:\n", + "\n", + "* **AWS account** with Amazon Bedrock AgentCore Payments available in your chosen region\n", + "* **Amazon Bedrock** access enabled for **Anthropic Claude Sonnet 4.5** in your chosen region\n", + "* **Python 3.10+** and Jupyter Notebook (or JupyterLab)\n", + "* **AWS CLI v2** configured with credentials (`aws configure`)\n", + "* **AWS CDK v2** installed globally (`npm install -g aws-cdk`) \u2014 used to deploy the seller\n", + "* **Node.js 18+** \u2014 required by CDK\n", + "* **AgentCore Payments botocore service definitions** available to your boto3 install (so boto3 knows how to call the service)\n", + "* **AgentCore Payments IAM roles** \u2014 the four roles (`ControlPlaneRole`, `ManagementRole`, `ProcessPaymentRole`, `ResourceRetrievalRole`) are created for you by the setup cell in \u00a72\n", + "* **A wallet provider account** \u2014 Coinbase CDP (API Key ID, API Key Secret, Wallet Secret) or Stripe via Privy (App ID, App Secret, Authorization Key ID, P-256 Authorization Private Key)\n", + "* **Testnet USDC** from [Circle Faucet](https://faucet.circle.com/) on both **Base Sepolia** and **Solana Devnet** \u2014 \u00a75 creates one wallet per network and you fund them inside the notebook after provisioning\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-02", + "metadata": {}, + "source": [ + "## 1. Install dependencies\n", + "\n", + "Run the cell below to install every Python dependency the notebook,\n", + "seller, and agent runtime need.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-03", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -r requirements.txt --quiet\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-04", + "metadata": {}, + "source": [ + "## 2. Configure environment\n", + "\n", + "The notebook reads everything from a `.env` file in this folder. Run the\n", + "cell below to:\n", + "\n", + "1. Create the four IAM roles the notebook will assume into (idempotent \u2014\n", + " safe to re-run). `setup-roles.sh` writes the role ARNs directly into\n", + " `.env` for you.\n", + "2. Open `.env` in an editor tab and list which values you still need to\n", + " paste in. Save the file, then **re-run this cell** to continue.\n", + "\n", + "You need values for:\n", + "\n", + "- `AWS_REGION` \u2014 one of `us-east-1`, `us-west-2`, `eu-central-1`,\n", + " `ap-southeast-2` (AgentCore Payments preview regions). Seeded from\n", + " the template as `us-west-2`; change if you prefer a different region.\n", + "- `COINBASE_API_KEY_ID`, `COINBASE_API_KEY_SECRET`, `COINBASE_WALLET_SECRET`\n", + " \u2014 from coinbase.com/developer-platform \u2192 Project \u2192 API Keys + Wallet.\n", + " Enable *Delegated signing* under Project \u2192 Wallet \u2192 Embedded Wallets \u2192\n", + " Policies before you use them.\n", + "- `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_AUTHORIZATION_ID`,\n", + " `PRIVY_AUTHORIZATION_PRIVATE_KEY` \u2014 from the Privy dashboard \u2192 App \u2192\n", + " API Keys + Authorization Keys. Strip the `wallet-auth:` prefix from\n", + " the private key before pasting.\n", + "- `INSTRUMENT_EMAIL` \u2014 a real inbox you control. The Coinbase Wallet Hub\n", + " and Privy both send verification mail there when you grant the agent\n", + " signing delegation in \u00a74.5.\n", + "- `SELLER_WALLET_ADDRESS`, `SELLER_SOLANA_WALLET_ADDRESS` \u2014 any testnet\n", + " addresses you control (the seller only receives funds).\n", + "\n", + "`USER_ID` is auto-generated for you the first time you run this cell.\n", + "\n", + "---\n", + "\n", + "#### Coinbase CDP \u2014 collect API + Wallet credentials\n", + "\n", + "Open the [Coinbase Developer Platform Portal](https://portal.cdp.coinbase.com/) and:\n", + "\n", + "1. Sign in (or create a free account) and select or create a **Project**.\n", + "2. Go to **API Keys** \u2192 **Create API key**. Copy:\n", + " - **Key ID** \u2192 `COINBASE_API_KEY_ID`\n", + " - **Key Secret** \u2192 `COINBASE_API_KEY_SECRET`\n", + "3. Go to **Wallet** \u2192 **Wallet Secret** \u2192 **Generate**. Copy the value\n", + " to `COINBASE_WALLET_SECRET`.\n", + "4. Go to **Wallet** \u2192 **Embedded Wallets** \u2192 **Policies** and enable\n", + " **Delegated signing**. AgentCore Payments cannot sign on the user's\n", + " behalf without this.\n", + "\n", + "#### Stripe via Privy \u2014 collect App + Authorization keys\n", + "\n", + "Open the [Privy Dashboard](https://dashboard.privy.io/) and:\n", + "\n", + "1. Sign in and select or create an **App**.\n", + "2. Go to **App settings** \u2192 **API Keys**. Copy:\n", + " - **App ID** \u2192 `PRIVY_APP_ID`\n", + " - **App secret** \u2192 `PRIVY_APP_SECRET`\n", + "3. Go to **App settings** \u2192 **Authorization Keys** \u2192 **Create new key**.\n", + " Copy:\n", + " - **Key ID** \u2192 `PRIVY_AUTHORIZATION_ID`\n", + " - **Private key** \u2192 `PRIVY_AUTHORIZATION_PRIVATE_KEY`. The dashboard\n", + " prefixes the value with `wallet-auth:` \u2014 strip the prefix before\n", + " pasting.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-04a", + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1: create the IAM roles. setup-roles.sh writes the four role\n", + "# ARNs to .env directly (idempotent \u2014 safe to re-run).\n", + "import shutil\n", + "import subprocess\n", + "from pathlib import Path\n", + "\n", + "USE_CASE = Path(\".\").resolve()\n", + "ENV_FILE = USE_CASE / \".env\"\n", + "TEMPLATE = USE_CASE / \"env-sample.txt\"\n", + "\n", + "if not ENV_FILE.exists():\n", + " shutil.copy2(TEMPLATE, ENV_FILE)\n", + " print(f\"\u2705 Seeded {ENV_FILE.name} from {TEMPLATE.name}\")\n", + "\n", + "roles_proc = subprocess.run(\n", + " [\"bash\", \"test/integration/setup-roles.sh\"],\n", + " check=False,\n", + ")\n", + "if roles_proc.returncode != 0:\n", + " raise RuntimeError(\n", + " f\"setup-roles.sh exited {roles_proc.returncode} \u2014 fix the error from the previous step and re-run this cell.\"\n", + " )\n", + "\n", + "# Step 2: remind users which keys they need to fill in manually in .env.\n", + "# We check what's still empty and list exactly those; the user pastes\n", + "# values in the .env tab that opens (or opens it themselves if the\n", + "# `code` CLI isn't available), saves, and re-runs this cell.\n", + "REQUIRED_MANUAL = [\n", + " (\"COINBASE_API_KEY_ID\", \"Coinbase CDP API key ID (coinbase.com/developer-platform \u2192 Project \u2192 API Keys)\"),\n", + " (\"COINBASE_API_KEY_SECRET\", \"Coinbase CDP API key secret\"),\n", + " (\"COINBASE_WALLET_SECRET\", \"Coinbase CDP wallet secret (Project \u2192 Wallet; enable Delegated signing first)\"),\n", + " (\"PRIVY_APP_ID\", \"Privy App ID (Privy dashboard \u2192 App \u2192 API Keys)\"),\n", + " (\"PRIVY_APP_SECRET\", \"Privy App Secret\"),\n", + " (\"PRIVY_AUTHORIZATION_ID\", \"Privy Authorization Key ID\"),\n", + " (\"PRIVY_AUTHORIZATION_PRIVATE_KEY\", \"Privy P-256 Authorization Private Key (strip 'wallet-auth:' prefix)\"),\n", + " (\"INSTRUMENT_EMAIL\", \"Real inbox you control \u2014 Coinbase Wallet Hub and Privy send verification here\"),\n", + " (\"SELLER_WALLET_ADDRESS\", \"EVM payout address (Base Sepolia) \u2014 any testnet 0x\u2026 address you control\"),\n", + " (\"SELLER_SOLANA_WALLET_ADDRESS\", \"Solana payout address (Solana Devnet) \u2014 any base58 address you control\"),\n", + "]\n", + "\n", + "# Parse current .env into a dict so we can see what's empty.\n", + "env_values = {}\n", + "for line in ENV_FILE.read_text().splitlines():\n", + " if \"=\" in line and not line.lstrip().startswith(\"#\"):\n", + " k, v = line.split(\"=\", 1)\n", + " env_values[k.strip()] = v.strip()\n", + "\n", + "missing = [\n", + " (key, hint) for key, hint in REQUIRED_MANUAL\n", + " if not env_values.get(key) or env_values[key].startswith(\"<\")\n", + "]\n", + "\n", + "# Auto-generate USER_ID as a UUID on first run so sessions don't collide\n", + "# across notebook runs. Only writes if currently empty.\n", + "if not env_values.get(\"USER_ID\"):\n", + " import uuid\n", + " from utils import write_env_updates\n", + " write_env_updates({\"USER_ID\": f\"pay-for-api-{uuid.uuid4()}\"}, env_path=str(ENV_FILE))\n", + " print(\"\u2705 Auto-wrote USER_ID as a fresh UUID\")\n", + "\n", + "if missing:\n", + " print(f\"\\n\u23f3 Fill in these {len(missing)} values in {ENV_FILE.name}:\\n\")\n", + " for key, hint in missing:\n", + " print(f\" \u2022 {key}\")\n", + " print(f\" {hint}\")\n", + " print(\n", + " f\"\\n Opening {ENV_FILE.name} in an editor tab. Paste the values,\\n\"\n", + " \" save, then re-run the NEXT cell (Environment check).\"\n", + " )\n", + " # VS Code ships `code` on PATH; fall back to a manual instruction.\n", + " try:\n", + " subprocess.run([\"code\", str(ENV_FILE)], check=False)\n", + " except FileNotFoundError:\n", + " print(f\"\\n Open manually: {ENV_FILE}\")\n", + "else:\n", + " print(\"\\n\u2705 All required .env values are set. Move on to the next cell.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-05", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from dotenv import load_dotenv\n", + "\n", + "load_dotenv(override=True)\n", + "\n", + "# \u2500\u2500 AWS configuration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# boto3 resolves the AgentCore Payments control-plane and data-plane\n", + "# endpoints from AWS_REGION, so no endpoint URLs are needed here.\n", + "AWS_REGION = os.environ.get(\"AWS_REGION\", \"us-west-2\")\n", + "\n", + "# \u2500\u2500 IAM role ARNs (from setup-roles.sh) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "CONTROL_PLANE_ROLE_ARN = os.environ.get(\"CONTROL_PLANE_ROLE_ARN\", \"\")\n", + "MANAGEMENT_ROLE_ARN = os.environ.get(\"MANAGEMENT_ROLE_ARN\", \"\")\n", + "PROCESS_PAYMENT_ROLE_ARN = os.environ.get(\"PROCESS_PAYMENT_ROLE_ARN\", \"\")\n", + "RESOURCE_RETRIEVAL_ROLE_ARN = os.environ.get(\"RESOURCE_RETRIEVAL_ROLE_ARN\", \"\")\n", + "\n", + "# \u2500\u2500 Wallet provider secrets \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# The notebook wires up BOTH providers side-by-side under the same\n", + "# Manager. Supply both credential sets in .env. You can leave one set\n", + "# empty to skip that provider \u2014 the setup cells check and warn.\n", + "COINBASE_API_KEY_ID = os.environ.get(\"COINBASE_API_KEY_ID\", \"\")\n", + "COINBASE_API_KEY_SECRET = os.environ.get(\"COINBASE_API_KEY_SECRET\", \"\")\n", + "COINBASE_WALLET_SECRET = os.environ.get(\"COINBASE_WALLET_SECRET\", \"\")\n", + "\n", + "PRIVY_APP_ID = os.environ.get(\"PRIVY_APP_ID\", \"\")\n", + "PRIVY_APP_SECRET = os.environ.get(\"PRIVY_APP_SECRET\", \"\")\n", + "PRIVY_AUTHORIZATION_ID = os.environ.get(\"PRIVY_AUTHORIZATION_ID\", \"\")\n", + "PRIVY_AUTHORIZATION_PRIVATE_KEY = os.environ.get(\"PRIVY_AUTHORIZATION_PRIVATE_KEY\", \"\")\n", + "\n", + "# \u2500\u2500 State populated by \u00a74 / \u00a75 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# One Manager. Two Connectors (one per provider). Four Instruments\n", + "# (EVM + SOL per provider). Two Sessions (one per provider).\n", + "MANAGER_ARN = os.environ.get(\"MANAGER_ARN\", \"\")\n", + "MANAGER_ID = os.environ.get(\"MANAGER_ID\", \"\")\n", + "\n", + "CREDENTIAL_PROVIDER_ARN_CDP = os.environ.get(\"CREDENTIAL_PROVIDER_ARN_CDP\", \"\")\n", + "CREDENTIAL_PROVIDER_ARN_PRIVY = os.environ.get(\"CREDENTIAL_PROVIDER_ARN_PRIVY\", \"\")\n", + "\n", + "CONNECTOR_ID_CDP = os.environ.get(\"CONNECTOR_ID_CDP\", \"\")\n", + "CONNECTOR_ID_PRIVY = os.environ.get(\"CONNECTOR_ID_PRIVY\", \"\")\n", + "\n", + "PAYMENT_INSTRUMENT_ID_CDP_EVM = os.environ.get(\"PAYMENT_INSTRUMENT_ID_CDP_EVM\", \"\")\n", + "WALLET_ADDRESS_CDP_EVM = os.environ.get(\"WALLET_ADDRESS_CDP_EVM\", \"\")\n", + "PAYMENT_INSTRUMENT_ID_CDP_SOL = os.environ.get(\"PAYMENT_INSTRUMENT_ID_CDP_SOL\", \"\")\n", + "WALLET_ADDRESS_CDP_SOL = os.environ.get(\"WALLET_ADDRESS_CDP_SOL\", \"\")\n", + "PAYMENT_INSTRUMENT_ID_PRIVY_EVM = os.environ.get(\"PAYMENT_INSTRUMENT_ID_PRIVY_EVM\", \"\")\n", + "WALLET_ADDRESS_PRIVY_EVM = os.environ.get(\"WALLET_ADDRESS_PRIVY_EVM\", \"\")\n", + "PAYMENT_INSTRUMENT_ID_PRIVY_SOL = os.environ.get(\"PAYMENT_INSTRUMENT_ID_PRIVY_SOL\", \"\")\n", + "WALLET_ADDRESS_PRIVY_SOL = os.environ.get(\"WALLET_ADDRESS_PRIVY_SOL\", \"\")\n", + "\n", + "SESSION_ID_CDP = os.environ.get(\"SESSION_ID_CDP\", \"\")\n", + "SESSION_ID_PRIVY = os.environ.get(\"SESSION_ID_PRIVY\", \"\")\n", + "\n", + "SELLER_API_URL = os.environ.get(\"SELLER_API_URL\", \"\").rstrip(\"/\")\n", + "\n", + "# Seller payout wallets \u2014 used at seller deploy time (\u00a73) and checked\n", + "# here so users can catch typos before deploy. Not read by the agent.\n", + "SELLER_WALLET_ADDRESS = os.environ.get(\"SELLER_WALLET_ADDRESS\", \"\")\n", + "SELLER_SOLANA_WALLET_ADDRESS = os.environ.get(\"SELLER_SOLANA_WALLET_ADDRESS\", \"\")\n", + "\n", + "# \u2500\u2500 Session and user config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "# USER_ID is auto-generated by \u00a72. If we somehow land here without\n", + "# one set, fall through to a UUID so CreatePaymentInstrument's required\n", + "# header stays populated (the service rejects empty strings).\n", + "import uuid as _uuid\n", + "USER_ID = os.environ.get(\"USER_ID\") or f\"pay-for-api-{_uuid.uuid4()}\"\n", + "INSTRUMENT_EMAIL = os.environ.get(\"INSTRUMENT_EMAIL\", f\"{USER_ID}@example.com\")\n", + "SESSION_MAX_SPEND = os.environ.get(\"SESSION_MAX_SPEND\", \"1.00\")\n", + "SESSION_EXPIRY_MINUTES = int(os.environ.get(\"SESSION_EXPIRY_MINUTES\", \"30\"))\n", + "\n", + "\n", + "def _check(label: str, value: str, redact: bool = False, optional: bool = False) -> bool:\n", + " ok = bool(value) and not value.startswith(\"<\")\n", + " display = \"[redacted]\" if redact and value else value\n", + " if optional and not ok:\n", + " display = display or \"(will be set later)\"\n", + " print(f\" \u23f3 {label}: {display}\")\n", + " return True\n", + " icon = \"\u2705\" if ok else \"\u274c MISSING\"\n", + " print(f\" {icon} {label}: {display}\")\n", + " return ok\n", + "\n", + "_required_results = []\n", + "def _req(label: str, value: str, redact: bool = False) -> None:\n", + " _required_results.append(_check(label, value, redact=redact, optional=False))\n", + "\n", + "print(\"=== Environment check ===\")\n", + "print(\"\\n AWS:\")\n", + "_req(\"AWS_REGION\", AWS_REGION)\n", + "\n", + "print(\"\\n IAM roles (from setup-roles.sh):\")\n", + "_req(\"CONTROL_PLANE_ROLE_ARN\", CONTROL_PLANE_ROLE_ARN)\n", + "_req(\"MANAGEMENT_ROLE_ARN\", MANAGEMENT_ROLE_ARN)\n", + "_req(\"PROCESS_PAYMENT_ROLE_ARN\", PROCESS_PAYMENT_ROLE_ARN)\n", + "_req(\"RESOURCE_RETRIEVAL_ROLE_ARN\", RESOURCE_RETRIEVAL_ROLE_ARN)\n", + "\n", + "print(\"\\n Coinbase CDP secrets:\")\n", + "_req(\"COINBASE_API_KEY_ID\", COINBASE_API_KEY_ID)\n", + "_req(\"COINBASE_API_KEY_SECRET\", COINBASE_API_KEY_SECRET, redact=True)\n", + "_req(\"COINBASE_WALLET_SECRET\", COINBASE_WALLET_SECRET, redact=True)\n", + "\n", + "print(\"\\n Stripe-via-Privy secrets:\")\n", + "_req(\"PRIVY_APP_ID\", PRIVY_APP_ID)\n", + "_req(\"PRIVY_APP_SECRET\", PRIVY_APP_SECRET, redact=True)\n", + "_req(\"PRIVY_AUTHORIZATION_ID\", PRIVY_AUTHORIZATION_ID)\n", + "_req(\"PRIVY_AUTHORIZATION_PRIVATE_KEY\", PRIVY_AUTHORIZATION_PRIVATE_KEY, redact=True)\n", + "\n", + "print(\"\\n Seller payout wallets (used by \u00a73 deploy):\")\n", + "_req(\"SELLER_WALLET_ADDRESS\", SELLER_WALLET_ADDRESS)\n", + "_req(\"SELLER_SOLANA_WALLET_ADDRESS\", SELLER_SOLANA_WALLET_ADDRESS)\n", + "\n", + "print(\"\\n Wallet linked email (used by \u00a74.5 CreatePaymentInstrument):\")\n", + "_req(\"INSTRUMENT_EMAIL\", INSTRUMENT_EMAIL)\n", + "\n", + "print(\"\\n Populated later \u2014 \u23f3 is expected on first run:\")\n", + "print(\" (\u00a73 writes SELLER_API_URL; \u00a74/\u00a75 write Manager, Connector, Instrument, Session IDs)\")\n", + "_check(\"MANAGER_ARN\", MANAGER_ARN, optional=True)\n", + "_check(\"PAYMENT_INSTRUMENT_ID_CDP_EVM\", PAYMENT_INSTRUMENT_ID_CDP_EVM, optional=True)\n", + "_check(\"PAYMENT_INSTRUMENT_ID_CDP_SOL\", PAYMENT_INSTRUMENT_ID_CDP_SOL, optional=True)\n", + "_check(\"PAYMENT_INSTRUMENT_ID_PRIVY_EVM\", PAYMENT_INSTRUMENT_ID_PRIVY_EVM, optional=True)\n", + "_check(\"PAYMENT_INSTRUMENT_ID_PRIVY_SOL\", PAYMENT_INSTRUMENT_ID_PRIVY_SOL, optional=True)\n", + "_check(\"SELLER_API_URL\", SELLER_API_URL, optional=True)\n", + "\n", + "# Guard: if any required value is missing, halt here so Run All doesn't\n", + "# cascade into \u00a73+ before the user has finished filling in .env.\n", + "if not all(_required_results):\n", + " missing_count = _required_results.count(False)\n", + " raise SystemExit(\n", + " f\"{missing_count} required value(s) missing from the previous step. Fill them in \"\n", + " f\".env (see the list printed by \u00a72), save, then re-run this cell.\"\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-06", + "metadata": {}, + "source": [ + "## 3. Deploy the Fun Facts seller\n", + "\n", + "The seller is a AWS Lambda-backed Amazon API Gateway HTTP API packaged as an AWS CDK stack.\n", + "It serves two endpoints:\n", + "\n", + "* `GET /health` \u2014 public, returns metadata about the API (price, network)\n", + "* `GET /facts?topic=` \u2014 paid; returns HTTP 402 unless a valid `X-PAYMENT`\n", + " header is attached\n", + "\n", + "The cell below invokes `deploy-seller.sh`, which (1) bootstraps CDK in this\n", + "account/region if needed, (2) runs `cdk deploy`, and (3) writes the resulting\n", + "API Gateway URL back to `.env` as `SELLER_API_URL`.\n", + "\n", + "> \ud83d\udca1 **Tip:** set the `PAY_TO` environment variable before running this cell to\n", + "> specify the address that should receive settled funds from the seller. Without\n", + "> it the CDK deploys with a placeholder and the facilitator will reject proofs\n", + "> at verification time.\n", + "\n", + "> \u26a0\ufe0f **Cost notice:** This deploys an Amazon API Gateway HTTP API\n", + "> and an AWS Lambda function. Both are billed per-request and\n", + "> generally sit comfortably inside the AWS Free Tier for tutorial\n", + "> usage. Run \u00a710 to tear them down when you are done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-07", + "metadata": {}, + "outputs": [], + "source": [ + "import pathlib\n", + "import subprocess\n", + "\n", + "HERE = pathlib.Path(\".\").resolve()\n", + "DEPLOY = HERE / \"test\" / \"integration\" / \"deploy-seller.sh\"\n", + "\n", + "if not DEPLOY.exists():\n", + " raise FileNotFoundError(\n", + " f\"Expected {DEPLOY} \u2014 run this notebook from the \"\n", + " \"02-use-cases/01-pay-for-api directory.\"\n", + " )\n", + "\n", + "# Stream output so long CDK deploys don't look frozen\n", + "proc = subprocess.Popen(\n", + " [\"bash\", str(DEPLOY)],\n", + " cwd=str(HERE),\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " text=True,\n", + " bufsize=1,\n", + ")\n", + "for line in proc.stdout:\n", + " print(line, end=\"\")\n", + "rc = proc.wait()\n", + "if rc != 0:\n", + " raise RuntimeError(f\"deploy-seller.sh failed with exit code {rc}\")\n", + "\n", + "# Reload the URL the script wrote to outputs.json\n", + "import json as _json\n", + "OUTPUTS_FILE = HERE / \"seller\" / \"cdk\" / \"outputs.json\"\n", + "if OUTPUTS_FILE.exists():\n", + " outs = _json.loads(OUTPUTS_FILE.read_text())\n", + " SELLER_API_URL = outs[\"AgentCorePaymentsFunFactsSellerStack\"][\"SellerApiUrl\"].rstrip(\"/\")\n", + " print(f\"\\n\u2705 SELLER_API_URL: {SELLER_API_URL}\")\n", + "else:\n", + " print(\"\\n\u26a0\ufe0f Could not read seller/cdk/outputs.json \u2014 paste the URL into .env manually.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-08", + "metadata": {}, + "source": [ + "### Sanity-check the seller\n", + "\n", + "Hit `/health` to confirm the seller is live. This endpoint requires no payment.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-09", + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "health = requests.get(f\"{SELLER_API_URL}/health\", timeout=10)\n", + "health.raise_for_status()\n", + "print(\"Seller health:\", health.json())\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "### Preview the 402 response\n", + "\n", + "This is exactly what the agent will see on its first call. The `accepts[0]` entry\n", + "is the x402 payment requirement \u2014 the agent hands it to `ProcessPayment` in \u00a76.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from utils import pp\n", + "\n", + "preview = requests.get(f\"{SELLER_API_URL}/facts?topic=space\", timeout=10)\n", + "print(f\"Status: {preview.status_code}\")\n", + "\n", + "# The seller returns 402 with a JSON body describing the payment\n", + "# requirement. Some x402 middleware versions return `{}` when the\n", + "# Accept header is application/json vs an empty body when it is text\n", + "# \u2014 handle both.\n", + "try:\n", + " body = preview.json()\n", + "except ValueError:\n", + " body = {}\n", + "pp(\"402 response body\", body)\n", + "\n", + "# Multi-network accepts: one entry per configured payout wallet.\n", + "# Each entry has `scheme`, `price` (human-readable USD string),\n", + "# `network` (Chain Agnostic Improvement Proposal 2 (CAIP-2) identifier, for example `eip155:84532`), and `payTo`. The x402\n", + "# middleware converts `$0.01` into the on-chain atomic amount (the smallest indivisible unit of the token; for USDC this is 0.000001 USDC).\n", + "accepts = body.get(\"accepts\") or []\n", + "if not accepts:\n", + " print(\n", + " \"\u2139\ufe0f No `accepts[]` array in the 402 body.\\n\"\n", + " \" This is expected for plain browser GETs against some x402\\n\"\n", + " \" facilitator versions \u2014 the agent sends an `Accept-Payment`\\n\"\n", + " \" header in \u00a77 which triggers the full payment requirement\\n\"\n", + " \" payload. You can skip this preview and continue.\"\n", + " )\n", + "else:\n", + " for i, entry in enumerate(accepts):\n", + " print(\n", + " f\" [{i}] scheme: {entry['scheme']} | \"\n", + " f\"network: {entry['network']} | \"\n", + " f\"price: {entry['price']} | \"\n", + " f\"payTo: {entry['payTo'] or '(unset)'}\"\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "## 4. Set up AgentCore Payments\n", + "\n", + "This section provisions everything AgentCore Payments needs:\n", + "\n", + "1. **Two Credential Providers** \u2014 one for Coinbase CDP, one for Stripe via Privy.\n", + "2. **One Payment Manager** \u2014 the top-level payment resource.\n", + "3. **Two Payment Connectors** \u2014 one per provider, attached to the same Manager.\n", + "4. **Four Payment Instruments** \u2014 EVM + SOLANA per provider. The agent\n", + " runs once per (provider, network) pair in \u00a77.\n", + "\n", + "Each step persists its output to `.env` so a kernel restart picks up\n", + "from where you left off.\n", + "\n", + "### 4.1 Assume roles and build clients\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "from boto3.session import Session\n", + "from utils import assume_role\n", + "\n", + "missing_roles = [\n", + " name for name, value in (\n", + " (\"CONTROL_PLANE_ROLE_ARN\", CONTROL_PLANE_ROLE_ARN),\n", + " (\"MANAGEMENT_ROLE_ARN\", MANAGEMENT_ROLE_ARN),\n", + " (\"RESOURCE_RETRIEVAL_ROLE_ARN\", RESOURCE_RETRIEVAL_ROLE_ARN),\n", + " ) if not value\n", + "]\n", + "if missing_roles:\n", + " raise RuntimeError(\n", + " f\"Missing IAM roles: {missing_roles}. Run `bash test/integration/setup-roles.sh`.\"\n", + " )\n", + "\n", + "boto_session = Session(region_name=AWS_REGION)\n", + "\n", + "print(\"Assuming ControlPlaneRole...\")\n", + "cp_session = assume_role(\n", + " boto_session, CONTROL_PLANE_ROLE_ARN, session_name=\"pay-for-api-cp\",\n", + ")\n", + "cp_client = cp_session.client(\"bedrock-agentcore-control\")\n", + "cred_client = cp_session.client(\"bedrock-agentcore-control\")\n", + "\n", + "print(\"\\nAssuming ManagementRole...\")\n", + "mgmt_session = assume_role(\n", + " boto_session, MANAGEMENT_ROLE_ARN, session_name=\"pay-for-api-mgmt\",\n", + ")\n", + "dp_client_mgmt = mgmt_session.client(\"bedrock-agentcore\")\n", + "\n", + "# INSTRUMENT_EMAIL was already loaded from .env in \u00a72. CreatePaymentInstrument\n", + "# requires it for every wallet \u2014 Coinbase and Privy both send verification\n", + "# mail there when the user grants the signing delegation.\n", + "print(f\"\\n\u2705 Clients ready\")\n", + "print(f\" INSTRUMENT_EMAIL: {INSTRUMENT_EMAIL}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "### 4.2 Create the Credential Providers\n", + "\n", + "`PaymentCredentialProvider` is the security handoff for this use case.\n", + "The notebook reads the wallet provider secrets from `.env` once and\n", + "calls `CreatePaymentCredentialProvider` against AgentCore Identity.\n", + "The service stores the API keys, app secrets, and wallet or\n", + "authorization secrets in **AWS Secrets Manager** under **AWS KMS**\n", + "encryption, and surfaces only the secret ARN to the agent. After this\n", + "cell runs, the secret material lives in the AgentCore-managed vault.\n", + "The agent runtime obtains a short-lived vendor-specific token through\n", + "`GetResourcePaymentToken` at signing time and never receives the raw\n", + "secret. The local `.env` copies are no longer needed at runtime and\n", + "can be cleared by hand if you want to remove them from disk.\n", + "\n", + "The notebook creates **one provider per vendor** so both can attach to\n", + "the same Manager later.\n", + "\n", + "For details, see [Creating a payment credential provider](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/resource-providers.html#creating-a-payment-credential-provider).\n", + "\n", + "#### 4.2a Coinbase CDP\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-17a", + "metadata": {}, + "outputs": [], + "source": [ + "import uuid\n", + "from utils import idempotent_create, write_env_updates\n", + "\n", + "\n", + "def _client_token() -> str:\n", + " # AgentCore Payments requires `clientToken` >= 33 chars (the value is an idempotency token: passing the same string on retry returns the existing resource instead of creating a duplicate). UUID v4 (36 chars) is safe.\n", + " return str(uuid.uuid4()) + \"-\" + str(uuid.uuid4())[:8]\n", + "\n", + "\n", + "if CREDENTIAL_PROVIDER_ARN_CDP:\n", + " print(f\"\u21b7 CREDENTIAL_PROVIDER_ARN_CDP already set in .env, skipping Coinbase CDP credential provider create\")\n", + "else:\n", + " missing_cdp = [\n", + " n for n, v in (\n", + " (\"COINBASE_API_KEY_ID\", COINBASE_API_KEY_ID),\n", + " (\"COINBASE_API_KEY_SECRET\", COINBASE_API_KEY_SECRET),\n", + " (\"COINBASE_WALLET_SECRET\", COINBASE_WALLET_SECRET),\n", + " ) if not v\n", + " ]\n", + " if missing_cdp:\n", + " raise RuntimeError(f\"Missing Coinbase CDP secrets in .env: {missing_cdp}\")\n", + "\n", + " CRED_PROVIDER_NAME_CDP = f\"PayForApiCDP{uuid.uuid4().hex[:8]}\"\n", + " resp = idempotent_create(\n", + " cred_client.create_payment_credential_provider,\n", + " conflict_msg=f\"Credential provider {CRED_PROVIDER_NAME_CDP} already exists\",\n", + " name=CRED_PROVIDER_NAME_CDP,\n", + " credentialProviderVendor=\"CoinbaseCDP\",\n", + " providerConfigurationInput={\n", + " \"coinbaseCdpConfiguration\": {\n", + " \"apiKeyId\": COINBASE_API_KEY_ID,\n", + " \"apiKeySecret\": COINBASE_API_KEY_SECRET,\n", + " \"walletSecret\": COINBASE_WALLET_SECRET,\n", + " }\n", + " },\n", + " )\n", + " if resp is None:\n", + " raise RuntimeError(\"Credential provider already exists \u2014 rename or delete it.\")\n", + " CREDENTIAL_PROVIDER_ARN_CDP = resp[\"credentialProviderArn\"]\n", + " print(f\"\u2705 Coinbase CDP credential provider: {CREDENTIAL_PROVIDER_ARN_CDP}\")\n", + "\n", + " write_env_updates({\n", + " \"CRED_PROVIDER_NAME_CDP\": CRED_PROVIDER_NAME_CDP,\n", + " \"CREDENTIAL_PROVIDER_ARN_CDP\": CREDENTIAL_PROVIDER_ARN_CDP,\n", + " })\n", + " print(\"\ud83d\udcbe .env updated: CRED_PROVIDER_NAME_CDP, CREDENTIAL_PROVIDER_ARN_CDP\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17b-hdr", + "metadata": {}, + "source": [ + "#### 4.2b Stripe via Privy\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-17b", + "metadata": {}, + "outputs": [], + "source": [ + "if CREDENTIAL_PROVIDER_ARN_PRIVY:\n", + " print(f\"\u21b7 CREDENTIAL_PROVIDER_ARN_PRIVY already set in .env, skipping Privy credential provider create\")\n", + "else:\n", + " missing_privy = [\n", + " n for n, v in (\n", + " (\"PRIVY_APP_ID\", PRIVY_APP_ID),\n", + " (\"PRIVY_APP_SECRET\", PRIVY_APP_SECRET),\n", + " (\"PRIVY_AUTHORIZATION_ID\", PRIVY_AUTHORIZATION_ID),\n", + " (\"PRIVY_AUTHORIZATION_PRIVATE_KEY\", PRIVY_AUTHORIZATION_PRIVATE_KEY),\n", + " ) if not v\n", + " ]\n", + " if missing_privy:\n", + " raise RuntimeError(f\"Missing Privy secrets in .env: {missing_privy}\")\n", + "\n", + " CRED_PROVIDER_NAME_PRIVY = f\"PayForApiPrivy{uuid.uuid4().hex[:8]}\"\n", + " resp = idempotent_create(\n", + " cred_client.create_payment_credential_provider,\n", + " conflict_msg=f\"Credential provider {CRED_PROVIDER_NAME_PRIVY} already exists\",\n", + " name=CRED_PROVIDER_NAME_PRIVY,\n", + " credentialProviderVendor=\"StripePrivy\",\n", + " providerConfigurationInput={\n", + " \"stripePrivyConfiguration\": {\n", + " \"appId\": PRIVY_APP_ID,\n", + " \"appSecret\": PRIVY_APP_SECRET,\n", + " \"authorizationId\": PRIVY_AUTHORIZATION_ID,\n", + " \"authorizationPrivateKey\": PRIVY_AUTHORIZATION_PRIVATE_KEY,\n", + " }\n", + " },\n", + " )\n", + " if resp is None:\n", + " raise RuntimeError(\"Credential provider already exists \u2014 rename or delete it.\")\n", + " CREDENTIAL_PROVIDER_ARN_PRIVY = resp[\"credentialProviderArn\"]\n", + " print(f\"\u2705 Stripe Privy credential provider: {CREDENTIAL_PROVIDER_ARN_PRIVY}\")\n", + "\n", + " write_env_updates({\n", + " \"CRED_PROVIDER_NAME_PRIVY\": CRED_PROVIDER_NAME_PRIVY,\n", + " \"CREDENTIAL_PROVIDER_ARN_PRIVY\": CREDENTIAL_PROVIDER_ARN_PRIVY,\n", + " })\n", + " print(\"\ud83d\udcbe .env updated: CRED_PROVIDER_NAME_PRIVY, CREDENTIAL_PROVIDER_ARN_PRIVY\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "### 4.3 Create the Payment Manager\n", + "\n", + "`PaymentManager` is the top-level payment resource. It takes the ARN\n", + "of `RESOURCE_RETRIEVAL_ROLE_ARN` \u2014 the role AgentCore Payments assumes\n", + "at runtime to retrieve the credentials you stored above.\n", + "\n", + "Manager creation is async \u2014 we poll `GetPaymentManager` until\n", + "`status == READY`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-19", + "metadata": {}, + "outputs": [], + "source": [ + "import re\n", + "from utils import wait_for_status\n", + "\n", + "if MANAGER_ARN:\n", + " print(f\"\u21b7 MANAGER_ARN already set in .env, skipping create:\\n {MANAGER_ARN}\")\n", + "else:\n", + " # PaymentManager names: letters + digits only, max 48 chars, no hyphens/underscores.\n", + " MANAGER_NAME = f\"PayForApi{uuid.uuid4().hex[:8]}\"\n", + " assert re.match(r\"^[a-zA-Z][a-zA-Z0-9]{0,47}$\", MANAGER_NAME), MANAGER_NAME\n", + "\n", + " resp = cp_client.create_payment_manager(\n", + " name=MANAGER_NAME,\n", + " description=f\"AgentCore Payments Pay for API use case {MANAGER_NAME}\",\n", + " authorizerType=\"AWS_IAM\",\n", + " roleArn=RESOURCE_RETRIEVAL_ROLE_ARN,\n", + " clientToken=_client_token(),\n", + " )\n", + " MANAGER_ID = resp[\"paymentManagerId\"]\n", + " MANAGER_ARN = resp[\"paymentManagerArn\"]\n", + " print(f\"\u2705 Payment Manager created\")\n", + " print(f\" Manager ID: {MANAGER_ID}\")\n", + " print(f\" Manager ARN: {MANAGER_ARN}\")\n", + "\n", + " print(\"\\nWaiting for PaymentManager to reach READY...\")\n", + " wait_for_status(\n", + " cp_client.get_payment_manager,\n", + " expected_status=\"READY\",\n", + " poll_interval=5,\n", + " timeout=120,\n", + " paymentManagerId=MANAGER_ID,\n", + " )\n", + " print(\"\u2705 PaymentManager is READY\")\n", + "\n", + " write_env_updates({\n", + " \"MANAGER_ID\": MANAGER_ID,\n", + " \"MANAGER_ARN\": MANAGER_ARN,\n", + " })\n", + " print(\"\ud83d\udcbe .env updated: MANAGER_ID, MANAGER_ARN\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-20", + "metadata": {}, + "source": [ + "### 4.4 Create the Payment Connectors\n", + "\n", + "`PaymentConnector` binds a Credential Provider to a Manager. The\n", + "connector's `type` tells AgentCore Payments which signer to use. We\n", + "create **two** connectors under the same Manager \u2014 one per provider.\n", + "\n", + "#### 4.4a Coinbase CDP connector\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-21", + "metadata": {}, + "outputs": [], + "source": [ + "if CONNECTOR_ID_CDP:\n", + " print(f\"\u21b7 CONNECTOR_ID_CDP already set in .env, skipping Coinbase CDP connector create\")\n", + "else:\n", + " CONNECTOR_NAME_CDP = f\"PayForApiCDPConn{uuid.uuid4().hex[:6]}\"\n", + " resp = cp_client.create_payment_connector(\n", + " paymentManagerId=MANAGER_ID,\n", + " name=CONNECTOR_NAME_CDP,\n", + " description=f\"AgentCore Payments CoinbaseCDP connector {CONNECTOR_NAME_CDP}\",\n", + " type=\"CoinbaseCDP\",\n", + " credentialProviderConfigurations=[\n", + " {\"coinbaseCDP\": {\"credentialProviderArn\": CREDENTIAL_PROVIDER_ARN_CDP}}\n", + " ],\n", + " clientToken=_client_token(),\n", + " )\n", + " CONNECTOR_ID_CDP = resp[\"paymentConnectorId\"]\n", + " print(f\"\u2705 Coinbase CDP connector: {CONNECTOR_ID_CDP}\")\n", + "\n", + " print(\"\\nWaiting for CDP connector to reach READY...\")\n", + " wait_for_status(\n", + " cp_client.get_payment_connector,\n", + " expected_status=\"READY\",\n", + " poll_interval=5,\n", + " timeout=120,\n", + " paymentManagerId=MANAGER_ID,\n", + " paymentConnectorId=CONNECTOR_ID_CDP,\n", + " )\n", + " print(\"\u2705 CDP connector is READY\")\n", + "\n", + " write_env_updates({\"CONNECTOR_ID_CDP\": CONNECTOR_ID_CDP})\n", + " print(\"\ud83d\udcbe .env updated: CONNECTOR_ID_CDP\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-21b-hdr", + "metadata": {}, + "source": [ + "#### 4.4b Stripe via Privy connector\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-21b", + "metadata": {}, + "outputs": [], + "source": [ + "if CONNECTOR_ID_PRIVY:\n", + " print(f\"\u21b7 CONNECTOR_ID_PRIVY already set in .env, skipping Privy connector create\")\n", + "else:\n", + " CONNECTOR_NAME_PRIVY = f\"PayForApiPrivyConn{uuid.uuid4().hex[:6]}\"\n", + " resp = cp_client.create_payment_connector(\n", + " paymentManagerId=MANAGER_ID,\n", + " name=CONNECTOR_NAME_PRIVY,\n", + " description=f\"AgentCore Payments StripePrivy connector {CONNECTOR_NAME_PRIVY}\",\n", + " type=\"StripePrivy\",\n", + " credentialProviderConfigurations=[\n", + " {\"stripePrivy\": {\"credentialProviderArn\": CREDENTIAL_PROVIDER_ARN_PRIVY}}\n", + " ],\n", + " clientToken=_client_token(),\n", + " )\n", + " CONNECTOR_ID_PRIVY = resp[\"paymentConnectorId\"]\n", + " print(f\"\u2705 Stripe Privy connector: {CONNECTOR_ID_PRIVY}\")\n", + "\n", + " print(\"\\nWaiting for Privy connector to reach READY...\")\n", + " wait_for_status(\n", + " cp_client.get_payment_connector,\n", + " expected_status=\"READY\",\n", + " poll_interval=5,\n", + " timeout=120,\n", + " paymentManagerId=MANAGER_ID,\n", + " paymentConnectorId=CONNECTOR_ID_PRIVY,\n", + " )\n", + " print(\"\u2705 Privy connector is READY\")\n", + "\n", + " write_env_updates({\"CONNECTOR_ID_PRIVY\": CONNECTOR_ID_PRIVY})\n", + " print(\"\ud83d\udcbe .env updated: CONNECTOR_ID_PRIVY\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22", + "metadata": {}, + "source": [ + "### 4.5 Create four Payment Instruments\n", + "\n", + "`EMBEDDED_CRYPTO_WALLET` is the only `paymentInstrumentType` available\n", + "today. `linkedAccounts` is required \u2014 AgentCore Payments uses the\n", + "email to resolve or create the vendor-side end-user that owns the\n", + "wallet.\n", + "\n", + "The notebook creates **four** instruments, two per connector:\n", + "\n", + "| Variable | Connector | `network` | Pay on |\n", + "|----------|-----------|-----------|--------|\n", + "| `PAYMENT_INSTRUMENT_ID_CDP_EVM` | CoinbaseCDP | `ETHEREUM` | Base Sepolia |\n", + "| `PAYMENT_INSTRUMENT_ID_CDP_SOL` | CoinbaseCDP | `SOLANA` | Solana Devnet |\n", + "| `PAYMENT_INSTRUMENT_ID_PRIVY_EVM` | StripePrivy | `ETHEREUM` | Base Sepolia |\n", + "| `PAYMENT_INSTRUMENT_ID_PRIVY_SOL` | StripePrivy | `SOLANA` | Solana Devnet |\n", + "\n", + "All four share the same linked email and the same operator `USER_ID`\n", + "header. The service assigns each its own vendor-side `userId` \u2014 \u00a76\n", + "reads that back via `GetPaymentInstrument` and threads it through\n", + "every subsequent data-plane call.\n", + "\n", + "For Coinbase wallets the response includes a `redirectUrl` to the\n", + "**Coinbase Wallet Hub**. Open it to verify the linked email, top up\n", + "with USDC, and grant the signing delegation that lets AgentCore\n", + "Payments sign on your behalf.\n", + "\n", + "Privy instruments do not return a `redirectUrl`. The Privy authorization\n", + "key you registered in \u00a74.2b is the wallet owner, but the end-user\n", + "still needs to grant the signer on the wallet before `ProcessPayment`\n", + "works. We cover the Privy delegation flow separately \u2014 \u00a77 skips the\n", + "Privy rows until that flow is wired up.\n", + "\n", + "A small helper runs the same CreatePaymentInstrument + wait +\n", + "GetPaymentInstrument cycle for each of the four wallets.\n", + "\n", + "#### 4.5a Coinbase CDP \u2014 ETHEREUM (Base Sepolia)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22a", + "metadata": {}, + "outputs": [], + "source": [ + "def create_instrument(*, connector_id: str, network: str, env_key_id: str,\n", + " env_key_wallet: str, label: str):\n", + " \"\"\"CreatePaymentInstrument + wait ACTIVE + re-fetch walletAddress.\n", + "\n", + " Returns (instrument_id, wallet_address, redirect_url).\n", + " Persists the two env keys as soon as values are known.\n", + " \"\"\"\n", + " print(f\"\\n\u2500\u2500 Creating {label} instrument ({network}) \u2500\u2500\")\n", + " resp = dp_client_mgmt.create_payment_instrument(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=connector_id,\n", + " userId=USER_ID,\n", + " paymentInstrumentType=\"EMBEDDED_CRYPTO_WALLET\",\n", + " paymentInstrumentDetails={\n", + " \"embeddedCryptoWallet\": {\n", + " \"network\": network,\n", + " \"linkedAccounts\": [{\"email\": {\"emailAddress\": INSTRUMENT_EMAIL}}],\n", + " }\n", + " },\n", + " clientToken=_client_token(),\n", + " )\n", + " instrument = resp[\"paymentInstrument\"]\n", + " instrument_id = instrument[\"paymentInstrumentId\"]\n", + " crypto = instrument[\"paymentInstrumentDetails\"][\"embeddedCryptoWallet\"]\n", + " wallet_address = crypto.get(\"walletAddress\", \"\")\n", + " redirect_url = crypto.get(\"redirectUrl\")\n", + "\n", + " print(f\" paymentInstrumentId: {instrument_id}\")\n", + " print(f\" walletAddress: {wallet_address or '(pending)'}\")\n", + " if redirect_url:\n", + " print(f\" redirectUrl: {redirect_url}\")\n", + "\n", + " print(f\" Waiting for {label} instrument to become ACTIVE...\")\n", + " wait_for_status(\n", + " dp_client_mgmt.get_payment_instrument,\n", + " expected_status=\"ACTIVE\",\n", + " poll_interval=5,\n", + " timeout=120,\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=USER_ID,\n", + " )\n", + " refreshed = dp_client_mgmt.get_payment_instrument(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=USER_ID,\n", + " )[\"paymentInstrument\"]\n", + " wallet_address = (\n", + " refreshed[\"paymentInstrumentDetails\"][\"embeddedCryptoWallet\"].get(\"walletAddress\", \"\")\n", + " )\n", + " print(f\" \u2705 {label} instrument ACTIVE\")\n", + "\n", + " write_env_updates({env_key_id: instrument_id, env_key_wallet: wallet_address})\n", + " print(f\"\ud83d\udcbe .env updated: {env_key_id}, {env_key_wallet}\")\n", + " return instrument_id, wallet_address, redirect_url\n", + "\n", + "\n", + "PAYMENT_INSTRUMENT_ID_CDP_EVM, WALLET_ADDRESS_CDP_EVM, REDIRECT_CDP_EVM = create_instrument(\n", + " connector_id=CONNECTOR_ID_CDP,\n", + " network=\"ETHEREUM\",\n", + " env_key_id=\"PAYMENT_INSTRUMENT_ID_CDP_EVM\",\n", + " env_key_wallet=\"WALLET_ADDRESS_CDP_EVM\",\n", + " label=\"CDP EVM\",\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\" FUND THIS WALLET WITH BASE SEPOLIA USDC\")\n", + "print(\"=\" * 60)\n", + "print(f\" Address: {WALLET_ADDRESS_CDP_EVM or '(still provisioning)'}\")\n", + "if REDIRECT_CDP_EVM:\n", + " print(f\" Hub: {REDIRECT_CDP_EVM}\")\n", + "print(\" Faucet: https://faucet.circle.com/ (pick Base Sepolia)\")\n", + "print(\"=\" * 60)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22a-hub", + "metadata": {}, + "source": [ + "##### Grant signing delegation in the Coinbase Wallet Hub\n", + "\n", + "Each Coinbase EMBEDDED_CRYPTO_WALLET creation returns a `redirectUrl`\n", + "to the Coinbase Wallet Hub printed by the previous cell. Open it and:\n", + "\n", + "1. **Sign in** with the email you set as `INSTRUMENT_EMAIL`. The hub\n", + " sends a one-time passcode (OTP) to that address.\n", + "\n", + "
\n", + " \"Coinbase\n", + "
\n", + "\n", + "2. **Enter the OTP** in the hub.\n", + "\n", + "
\n", + " \"Coinbase\n", + "
\n", + "\n", + "3. **Grant signing delegation** to the agent. Without this step,\n", + " `ProcessPayment` returns *Delegated signing grant is not active*.\n", + "4. **Set the delegation duration** \u2014 how long the grant should\n", + " remain active.\n", + "\n", + "
\n", + " \"Grant\n", + "
\n", + "\n", + "5. **Copy the EVM wallet address** printed by the previous cell.\n", + "6. **Request testnet USDC** from the\n", + " [Circle faucet](https://faucet.circle.com/) on **Base Sepolia**\n", + " using the copied address.\n", + "7. **Toggle to Solana** in the hub.\n", + "8. **Select Solana Devnet** in the network dropdown.\n", + "9. **Copy the Solana wallet address**.\n", + "10. **Request testnet USDC** from the same faucet under\n", + " **Solana Devnet** using the copied Solana address.\n", + "\n", + "Once both wallets show a balance, run \u00a74.5b in the following section to create the\n", + "Solana instrument under the same delegation.\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22b-hdr", + "metadata": {}, + "source": [ + "#### 4.5b Coinbase CDP \u2014 SOLANA (Solana Devnet)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22b", + "metadata": {}, + "outputs": [], + "source": [ + "PAYMENT_INSTRUMENT_ID_CDP_SOL, WALLET_ADDRESS_CDP_SOL, REDIRECT_CDP_SOL = create_instrument(\n", + " connector_id=CONNECTOR_ID_CDP,\n", + " network=\"SOLANA\",\n", + " env_key_id=\"PAYMENT_INSTRUMENT_ID_CDP_SOL\",\n", + " env_key_wallet=\"WALLET_ADDRESS_CDP_SOL\",\n", + " label=\"CDP SOLANA\",\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\" FUND THIS WALLET WITH SOLANA DEVNET USDC\")\n", + "print(\"=\" * 60)\n", + "print(f\" Address: {WALLET_ADDRESS_CDP_SOL or '(still provisioning)'}\")\n", + "if REDIRECT_CDP_SOL:\n", + " print(f\" Hub: {REDIRECT_CDP_SOL}\")\n", + "print(\" Faucet: https://faucet.circle.com/ (pick Solana Devnet)\")\n", + "print(\"=\" * 60)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22c-hdr", + "metadata": {}, + "source": [ + "#### 4.5c Stripe via Privy \u2014 ETHEREUM (Base Sepolia)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22c", + "metadata": {}, + "outputs": [], + "source": [ + "PAYMENT_INSTRUMENT_ID_PRIVY_EVM, WALLET_ADDRESS_PRIVY_EVM, REDIRECT_PRIVY_EVM = create_instrument(\n", + " connector_id=CONNECTOR_ID_PRIVY,\n", + " network=\"ETHEREUM\",\n", + " env_key_id=\"PAYMENT_INSTRUMENT_ID_PRIVY_EVM\",\n", + " env_key_wallet=\"WALLET_ADDRESS_PRIVY_EVM\",\n", + " label=\"Privy EVM\",\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\" Privy EVM wallet created.\")\n", + "print(\"=\" * 60)\n", + "print(f\" Address: {WALLET_ADDRESS_PRIVY_EVM or '(still provisioning)'}\")\n", + "print(\" Note: Grant signing delegation via the Privy Wallet Hub\")\n", + "print(\" frontend (see \u00a74.5e below) before \u00a77 can spend from\")\n", + "print(\" this wallet. Once delegation is granted, \u00a77 spends here too.\")\n", + "print(\"=\" * 60)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22d-hdr", + "metadata": {}, + "source": [ + "#### 4.5d Stripe via Privy \u2014 SOLANA (Solana Devnet)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22d", + "metadata": {}, + "outputs": [], + "source": [ + "PAYMENT_INSTRUMENT_ID_PRIVY_SOL, WALLET_ADDRESS_PRIVY_SOL, REDIRECT_PRIVY_SOL = create_instrument(\n", + " connector_id=CONNECTOR_ID_PRIVY,\n", + " network=\"SOLANA\",\n", + " env_key_id=\"PAYMENT_INSTRUMENT_ID_PRIVY_SOL\",\n", + " env_key_wallet=\"WALLET_ADDRESS_PRIVY_SOL\",\n", + " label=\"Privy SOLANA\",\n", + ")\n", + "\n", + "print(\"\\n\" + \"=\" * 60)\n", + "print(\" Privy Solana wallet created.\")\n", + "print(\"=\" * 60)\n", + "print(f\" Address: {WALLET_ADDRESS_PRIVY_SOL or '(still provisioning)'}\")\n", + "print(\" Note: Grant signing delegation via the Privy Wallet Hub frontend (\u00a74.5e).\")\n", + "print(\"=\" * 60)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-22e-privy-hub-md", + "metadata": {}, + "source": [ + "##### Grant signing delegation in the Privy Wallet Hub\n", + "\n", + "Privy embedded wallets need a separate **signing delegation** before\n", + "the agent can call `ProcessPayment` against them. The delegation is\n", + "granted from a small Next.js frontend maintained jointly by Privy and\n", + "the AWS AgentCore Bedrock team at\n", + "[privy-io/aws-agentcore-sdk](https://github.com/privy-io/aws-agentcore-sdk).\n", + "\n", + "Run the four cells below in order:\n", + "\n", + "1. **Clone** \u2014 pulls the frontend into `privy-delegation/`.\n", + "2. **Generate env** \u2014 writes `privy-delegation/.env.local` from the\n", + " `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, and `PRIVY_AUTHORIZATION_ID`\n", + " you already have in `.env`, and pins the frontend to **testnet**\n", + " so it reads Base Sepolia + Solana Devnet balances.\n", + "3. **Install** \u2014 runs `npm install` (idempotent).\n", + "4. **Start** \u2014 launches `npm run dev` in the background and waits\n", + " until the server is up at `http://localhost:3000` (loopback only,\n", + " no TLS needed for a local dev server).\n", + "\n", + "Open [http://localhost:3000](http://localhost:3000) and:\n", + "\n", + "1. **Log in** with the email you set as `INSTRUMENT_EMAIL`. Privy sends\n", + " a one-time passcode (OTP) to that address.\n", + "\n", + "
\n", + " \"Privy\n", + "
\n", + "2. **View your wallets.** The hub queries Base Sepolia and Solana\n", + " Devnet directly and shows USDC balances for both.\n", + "3. **Delegate access to the agent.** Choose **Delegate** for each wallet\n", + " you want the agent to spend from. Without this, `ProcessPayment`\n", + " returns `Delegated signing grant is not active`.\n", + "\n", + "
\n", + " \"Granting\n", + "
\n", + "4. **Choose Receive** in the hub to copy the wallet address.\n", + "5. **Request testnet USDC** from the\n", + " [Circle faucet](https://faucet.circle.com/) on **Base Sepolia** or\n", + " **Solana Devnet** using the copied address. Card funding via\n", + " Stripe is mainnet-only and is disabled when\n", + " `NEXT_PUBLIC_NETWORK_MODE=testnet`.\n", + "\n", + "Once both wallets show a balance and delegation is granted, you can\n", + "run the stop cell below to shut the frontend down and continue with \u00a75.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22e-hub-clone", + "metadata": {}, + "outputs": [], + "source": [ + "# Clone the Privy Wallet Hub frontend if it isn't already in this folder.\n", + "#\n", + "# The frontend lives upstream at privy-io/aws-agentcore-sdk and is\n", + "# jointly maintained by Privy and the AWS AgentCore Bedrock team. We\n", + "# clone it locally as `privy-delegation/` (the upstream repo dir name\n", + "# would be `aws-agentcore-sdk` \u2014 we pass an explicit target so every\n", + "# downstream cell can stay on the same path). Re-running this cell is\n", + "# a no-op once the folder exists.\n", + "import pathlib\n", + "import shutil\n", + "import subprocess\n", + "\n", + "HUB_ROOT = pathlib.Path(\"privy-delegation\").resolve()\n", + "PRIVY_REMOTE = \"https://github.com/privy-io/aws-agentcore-sdk.git\"\n", + "\n", + "if HUB_ROOT.exists():\n", + " print(f\"\u21b7 {HUB_ROOT.name}/ already present, skipping clone.\")\n", + "else:\n", + " if not shutil.which(\"git\"):\n", + " raise RuntimeError(\n", + " \"git not found on PATH. Install git from https://git-scm.com/ \"\n", + " \"and re-run this cell.\"\n", + " )\n", + " print(f\"Cloning {PRIVY_REMOTE} into {HUB_ROOT.name}/ ...\")\n", + " subprocess.run(\n", + " [\"git\", \"clone\", \"--depth\", \"1\", PRIVY_REMOTE, str(HUB_ROOT)],\n", + " check=True,\n", + " )\n", + " print(\"\u2705 Clone complete.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22e-privy-hub", + "metadata": {}, + "outputs": [], + "source": [ + "# Generate privy-delegation/.env.local for the Privy Wallet Hub frontend.\n", + "#\n", + "# We map our parent .env keys onto the names the frontend expects and\n", + "# pin NETWORK_MODE=testnet so balances + transfers target Base Sepolia\n", + "# and Solana Devnet (matching the rest of this notebook). Re-running\n", + "# the cell overwrites .env.local idempotently.\n", + "import pathlib\n", + "\n", + "HUB_ROOT = pathlib.Path(\"privy-delegation\").resolve()\n", + "HUB_ENV = HUB_ROOT / \".env.local\"\n", + "\n", + "missing_for_hub = [\n", + " n for n, v in (\n", + " (\"PRIVY_APP_ID\", PRIVY_APP_ID),\n", + " (\"PRIVY_APP_SECRET\", PRIVY_APP_SECRET),\n", + " (\"PRIVY_AUTHORIZATION_ID\", PRIVY_AUTHORIZATION_ID),\n", + " ) if not v\n", + "]\n", + "if missing_for_hub:\n", + " raise RuntimeError(\n", + " f\"Cannot write {HUB_ENV} \u2014 missing in parent .env: {missing_for_hub}\"\n", + " )\n", + "\n", + "HUB_ENV.write_text(\n", + " f\"# Generated by pay-for-api.ipynb. Do not commit.\\n\"\n", + " f\"# Mapped from the parent .env so the Privy Wallet Hub frontend\\n\"\n", + " f\"# reads the same App ID / App Secret / Signer ID this notebook uses.\\n\"\n", + " f\"NEXT_PUBLIC_PRIVY_APP_ID={PRIVY_APP_ID}\\n\"\n", + " f\"PRIVY_APP_SECRET={PRIVY_APP_SECRET}\\n\"\n", + " f\"NEXT_PUBLIC_PRIVY_SIGNER_ID={PRIVY_AUTHORIZATION_ID}\\n\"\n", + " f\"NEXT_PUBLIC_NETWORK_MODE=testnet\\n\"\n", + ")\n", + "print(f\"\u2705 Wrote {HUB_ENV}\")\n", + "print(f\" App ID: {PRIVY_APP_ID}\")\n", + "print(f\" Signer ID: {PRIVY_AUTHORIZATION_ID}\")\n", + "print(f\" Network: testnet (Base Sepolia + Solana Devnet)\")\n", + "print()\n", + "print(\"Next: run the install + start cells that follow to bring the hub up.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22e-hub-install", + "metadata": {}, + "outputs": [], + "source": [ + "# Install the Privy Wallet Hub frontend's Node dependencies. npm ships\n", + "# with Node.js, so no extra package manager setup is needed. This cell\n", + "# is idempotent \u2014 it skips the install if node_modules/ already exists.\n", + "import pathlib\n", + "import shutil\n", + "import subprocess\n", + "\n", + "HUB_ROOT = pathlib.Path(\"privy-delegation\").resolve()\n", + "\n", + "if not shutil.which(\"npm\"):\n", + " raise RuntimeError(\n", + " \"npm not found on PATH. Install Node.js (which includes npm) \"\n", + " \"from https://nodejs.org/ or via your package manager.\"\n", + " )\n", + "\n", + "if (HUB_ROOT / \"node_modules\").exists():\n", + " print(f\"\u21b7 {HUB_ROOT.name}/node_modules already present, skipping install.\")\n", + "else:\n", + " print(f\"Running npm install in {HUB_ROOT}...\")\n", + " subprocess.run([\"npm\", \"install\"], cwd=HUB_ROOT, check=True)\n", + " print(\"\u2705 npm install complete.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22e-hub-start", + "metadata": {}, + "outputs": [], + "source": [ + "# Start the Privy Wallet Hub frontend in the background and wait until\n", + "# the dev server is responding on http://localhost:3000. The PID is\n", + "# stored in HUB_DEV_PROCESS so the stop cell below can terminate it.\n", + "import os\n", + "import pathlib\n", + "import socket\n", + "import subprocess\n", + "import time\n", + "\n", + "HUB_ROOT = pathlib.Path(\"privy-delegation\").resolve()\n", + "HUB_PORT = 3000\n", + "HUB_URL = f\"http://localhost:{HUB_PORT}\"\n", + "\n", + "\n", + "def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:\n", + " with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n", + " s.settimeout(timeout)\n", + " try:\n", + " s.connect((host, port))\n", + " return True\n", + " except (ConnectionRefusedError, socket.timeout, OSError):\n", + " return False\n", + "\n", + "\n", + "# Reuse an existing dev server if one is already running on 3000.\n", + "already_running = _port_open(\"127.0.0.1\", HUB_PORT)\n", + "if already_running:\n", + " print(f\"\u21b7 Something is already listening on port {HUB_PORT}; reusing it.\")\n", + " HUB_DEV_PROCESS = None\n", + "else:\n", + " log_path = HUB_ROOT / \"npm-dev.log\"\n", + " log_handle = open(log_path, \"w\")\n", + " HUB_DEV_PROCESS = subprocess.Popen(\n", + " [\"npm\", \"run\", \"dev\"],\n", + " cwd=HUB_ROOT,\n", + " stdout=log_handle,\n", + " stderr=subprocess.STDOUT,\n", + " # Detach into a new process group so the kernel does not kill\n", + " # the dev server when this cell finishes.\n", + " start_new_session=True,\n", + " )\n", + " print(f\"Started npm dev (PID {HUB_DEV_PROCESS.pid}); logs: {log_path}\")\n", + " print(f\"Waiting for {HUB_URL} to come up...\")\n", + " deadline = time.time() + 90 # Next.js cold start can take up to a minute\n", + " while time.time() < deadline:\n", + " if _port_open(\"127.0.0.1\", HUB_PORT):\n", + " break\n", + " if HUB_DEV_PROCESS.poll() is not None:\n", + " raise RuntimeError(\n", + " f\"npm dev exited early (rc={HUB_DEV_PROCESS.returncode}); \"\n", + " f\"see {log_path} for details.\"\n", + " )\n", + " time.sleep(1)\n", + " else:\n", + " raise RuntimeError(\n", + " f\"npm dev did not respond on port {HUB_PORT} within 90s. \"\n", + " f\"See {log_path} for details.\"\n", + " )\n", + " print(f\"\u2705 Hub is up at {HUB_URL}\")\n", + "\n", + "print()\n", + "print(f\"Open {HUB_URL} in your browser, sign in with INSTRUMENT_EMAIL,\")\n", + "print(\"delegate each wallet, fund via the Circle faucet, then run the\")\n", + "print(\"STOP cell below to shut the dev server down.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22e-hub-stop", + "metadata": {}, + "outputs": [], + "source": [ + "# Stop the Privy Wallet Hub frontend started above. Skip if the start\n", + "# cell reused an already-running server (HUB_DEV_PROCESS is None).\n", + "import os\n", + "import signal\n", + "\n", + "proc = globals().get(\"HUB_DEV_PROCESS\")\n", + "if proc is None:\n", + " print(\"\u21b7 No background npm dev process to stop (server was reused).\")\n", + "elif proc.poll() is not None:\n", + " print(f\"\u21b7 npm dev already exited (rc={proc.returncode}).\")\n", + "else:\n", + " # Killing the process group catches the Next.js dev server's\n", + " # child workers as well as the npm wrapper.\n", + " try:\n", + " os.killpg(os.getpgid(proc.pid), signal.SIGTERM)\n", + " except ProcessLookupError:\n", + " pass\n", + " try:\n", + " proc.wait(timeout=10)\n", + " except Exception:\n", + " os.killpg(os.getpgid(proc.pid), signal.SIGKILL)\n", + " print(f\"\u2705 Stopped npm dev (PID {proc.pid}).\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-sec5", + "metadata": {}, + "source": [ + "## 5. Create payment sessions\n", + "\n", + "A **payment session** is a budget-limited, time-bounded authorization\n", + "for the agent to spend. The operator (you, via the `ManagementRole`)\n", + "sets `maxSpendAmount` and `expiryTimeInMinutes`. Once created, the\n", + "agent can only spend within that budget \u2014 it cannot raise the limit\n", + "or extend the session.\n", + "\n", + "Sessions are Manager-scoped, not connector- or instrument-scoped \u2014 a\n", + "single session covers every instrument the agent pays from. To keep\n", + "the CDP and Privy spend ledgers separate for the demo, the notebook creates **one\n", + "session per provider**: the CDP session covers the two CDP instruments\n", + "(EVM + SOL), the Privy session covers the two Privy instruments.\n", + "\n", + "Notes:\n", + "\n", + "* `expiryTimeInMinutes` is required (range 15\u2013480).\n", + "* `clientToken` must be \u2265 33 characters. UUID v4 (36) satisfies this.\n", + "* `userId` is an HTTP header, not a body field.\n", + "\n", + "### 5.1 Build session clients\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-sec5-clients", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "\n", + "boto_session = Session(region_name=AWS_REGION)\n", + "\n", + "# \u2500\u2500 Management client (ManagementRole \u2014 creates sessions) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "mgmt_session = assume_role(\n", + " boto_session, MANAGEMENT_ROLE_ARN, session_name=\"pay-for-api-mgmt-session\",\n", + ")\n", + "dp_client = mgmt_session.client(\"bedrock-agentcore\")\n", + "\n", + "# \u2500\u2500 Agent client (ProcessPaymentRole \u2014 signs payments) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n", + "agent_session = assume_role(\n", + " boto_session, PROCESS_PAYMENT_ROLE_ARN, session_name=\"pay-for-api-agent\",\n", + ")\n", + "dp_agent_client = agent_session.client(\"bedrock-agentcore\")\n", + "\n", + "print(\"\\n\u2705 Session clients ready\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-sec5a-hdr", + "metadata": {}, + "source": [ + "### 5.2 Coinbase CDP session\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-sec5a", + "metadata": {}, + "outputs": [], + "source": [ + "resp = dp_client.create_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " userId=USER_ID,\n", + " expiryTimeInMinutes=SESSION_EXPIRY_MINUTES,\n", + " limits={\"maxSpendAmount\": {\"value\": SESSION_MAX_SPEND, \"currency\": \"USD\"}},\n", + " clientToken=_client_token(),\n", + ")\n", + "SESSION_ID_CDP = resp[\"paymentSession\"][\"paymentSessionId\"]\n", + "print(f\"\u2705 Coinbase CDP session created\")\n", + "print(f\" Session ID: {SESSION_ID_CDP}\")\n", + "print(f\" Budget: ${SESSION_MAX_SPEND} USD (covers CDP EVM + SOL)\")\n", + "print(f\" Expires in: {SESSION_EXPIRY_MINUTES} minutes\")\n", + "\n", + "write_env_updates({\"SESSION_ID_CDP\": SESSION_ID_CDP})\n", + "print(\"\ud83d\udcbe .env updated: SESSION_ID_CDP\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-sec5b-hdr", + "metadata": {}, + "source": [ + "### 5.3 Stripe via Privy session\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-sec5b", + "metadata": {}, + "outputs": [], + "source": [ + "resp = dp_client.create_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " userId=USER_ID,\n", + " expiryTimeInMinutes=SESSION_EXPIRY_MINUTES,\n", + " limits={\"maxSpendAmount\": {\"value\": SESSION_MAX_SPEND, \"currency\": \"USD\"}},\n", + " clientToken=_client_token(),\n", + ")\n", + "SESSION_ID_PRIVY = resp[\"paymentSession\"][\"paymentSessionId\"]\n", + "print(f\"\u2705 Stripe Privy session created\")\n", + "print(f\" Session ID: {SESSION_ID_PRIVY}\")\n", + "print(f\" Budget: ${SESSION_MAX_SPEND} USD (covers Privy EVM + SOL)\")\n", + "print(f\" Expires in: {SESSION_EXPIRY_MINUTES} minutes\")\n", + "\n", + "write_env_updates({\"SESSION_ID_PRIVY\": SESSION_ID_PRIVY})\n", + "print(\"\ud83d\udcbe .env updated: SESSION_ID_PRIVY\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "a6bbdeae", + "metadata": {}, + "source": [ + "## 6. Build the Fun Facts agent\n", + "\n", + "this use case builds **one** minimal Strands agent pattern \u2014 one tool\n", + "(`http_request` from `strands-agents-tools`) plus an\n", + "`AgentCorePaymentsPlugin` configured for a specific (instrument,\n", + "session) pair. Since the plugin's `AgentCorePaymentsPluginConfig`\n", + "pins a single `payment_instrument_id` + `payment_session_id` + network\n", + "preference, we rebuild the agent from the same factory whenever we\n", + "want to pay on a different network. \u00a76 runs it once on EVM and once\n", + "on Solana against the same seller.\n", + "\n", + "The payment flow is identical on both runs:\n", + "\n", + "1. The agent calls `http_request` `GET /facts?topic=`\n", + "2. Seller returns **HTTP 402** with an x402 `accepts` array covering\n", + " both Base Sepolia and Solana Devnet\n", + "3. `AgentCorePaymentsPlugin` intercepts and calls **`ProcessPayment`**\n", + " against that run's (manager, session, instrument, user). The\n", + " `network_preferences_config` passed into the factory decides which\n", + " entry from `accepts[]` the plugin picks first.\n", + "4. Seller verifies and settles through the x402 facilitator, returns\n", + " **200 OK** with the paid fact.\n", + "\n", + "The agent flow is identical across wallet providers too \u2014 Coinbase CDP\n", + "or Stripe via Privy. The service picks the right signer.\n", + "\n", + "> The plugin also registers three read-only management tools at runtime\n", + "> \u2014 `get_payment_instrument`, `list_payment_instruments`,\n", + "> `get_payment_session`. The system prompt below instructs the model to\n", + "> use only `http_request`; the plugin's tools are reserved for operator\n", + "> and debug flows. The plugin also exposes a\n", + "> `PaymentInstrumentConfigurationRequired` /\n", + "> `PaymentSessionConfigurationRequired` interrupt contract for agents\n", + "> that need to prompt the operator mid-run; see the [Strands SDK\n", + "> reference](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-process-payment.html)\n", + "> for the full configuration surface including `auto_payment=False`\n", + "> for human-in-the-loop flows.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb4e5a85", + "metadata": {}, + "outputs": [], + "source": [ + "from strands import Agent\n", + "from strands.models.bedrock import BedrockModel\n", + "from strands_tools import http_request\n", + "\n", + "from bedrock_agentcore.payments.integrations.config import (\n", + " AgentCorePaymentsPluginConfig,\n", + ")\n", + "from bedrock_agentcore.payments.integrations.strands.plugin import (\n", + " AgentCorePaymentsPlugin,\n", + ")\n", + "\n", + "SYSTEM_PROMPT = (\n", + " \"You are a research agent backed by Amazon Bedrock AgentCore Payments. \"\n", + " \"Your only tool is `http_request`. The AgentCorePaymentsPlugin watches \"\n", + " \"every request and, when it sees an HTTP 402, silently calls \"\n", + " \"ProcessPayment and retries with an `X-PAYMENT` header \u2014 you never \"\n", + " \"handle private keys, assemble headers, or budget limits.\\n\"\n", + " \"\\n\"\n", + " \"The plugin also registers three read-only tools \u2014 \"\n", + " \"`get_payment_instrument`, `list_payment_instruments`, \"\n", + " \"`get_payment_session` \u2014 which the agent should not call. They exist for \"\n", + " \"operator debug flows, not for you. Use only `http_request`.\\n\"\n", + " \"\\n\"\n", + " \"SELLER\\n\"\n", + " \" Endpoint: GET /facts?topic=\\n\"\n", + " \" Topics: space, oceans, ai, payments (anything else returns a \"\n", + " \"random general fact)\\n\"\n", + " \" Price: $0.01 USDC per successful call\\n\"\n", + " \" Response: {'x402_content': {'data': '', ...}, \"\n", + " \"'x402_meta': {...}}\\n\"\n", + " \" `x402_content.data` is a JSON string; parse it to read \"\n", + " \"`{'topic': ..., 'fact': ...}`.\\n\"\n", + " \"\\n\"\n", + " \"RULES\\n\"\n", + " \" 1. Make one `http_request` GET per topic the user asks about \u2014 if \"\n", + " \"they ask for two, make two.\\n\"\n", + " \" 2. If the user names a topic outside the supported list, pick the \"\n", + " \"closest supported one (e.g. volcanoes\u2192space, whales\u2192oceans) rather \"\n", + " \"than letting the seller fall back silently.\\n\"\n", + " \" 3. Parse `x402_content.data` and quote the `fact` verbatim in your \"\n", + " \"reply.\\n\"\n", + " \" 4. Always close with a one-line spend summary: \"\n", + " \"`Total spent: $<0.01 \u00d7 successful_calls>`.\\n\"\n", + " \" 5. If a call returns anything other than 200, explain the error in \"\n", + " \"plain language and do not retry \u2014 the plugin already retried once.\"\n", + ")\n", + "\n", + "# Shared Bedrock model \u2014 Claude Sonnet 4.5 via the cross-region US\n", + "# inference profile. We reuse the same BedrockModel instance across runs\n", + "# since it has no per-run state.\n", + "model = BedrockModel(\n", + " model_id=\"us.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n", + " region_name=AWS_REGION,\n", + " temperature=0.7,\n", + ")\n", + "\n", + "\n", + "def resolve_payment_user_id(instrument_id: str) -> str:\n", + " \"\"\"Pull paymentInstrument.userId for the given instrument.\n", + "\n", + " The service assigns this value on CreatePaymentInstrument and every\n", + " downstream op (ProcessPayment, GetPaymentSession,\n", + " GetPaymentInstrumentBalance) must target it \u2014 the operator USER_ID\n", + " on the request header is not what the service records.\n", + " \"\"\"\n", + " resp = dp_client.get_payment_instrument(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=USER_ID,\n", + " )\n", + " return resp[\"paymentInstrument\"][\"userId\"]\n", + "\n", + "\n", + "def build_agent(*, instrument_id: str, session_id: str,\n", + " network_preferences: list[str], label: str) -> Agent:\n", + " \"\"\"Build a fresh Strands agent scoped to one (instrument, session).\n", + "\n", + " Because AgentCorePaymentsPluginConfig pins a single instrument and\n", + " session, we rebuild the agent per run rather than holding multiple\n", + " instances. `network_preferences` is a CAIP-2 list (priority order)\n", + " telling the plugin which entry from the seller's 402 accepts[] to\n", + " match against the instrument's network.\n", + " \"\"\"\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " print(f\" {label} paymentUserId: {payment_user_id}\")\n", + "\n", + " plugin = AgentCorePaymentsPlugin(\n", + " config=AgentCorePaymentsPluginConfig(\n", + " payment_manager_arn=MANAGER_ARN,\n", + " user_id=payment_user_id,\n", + " payment_instrument_id=instrument_id,\n", + " payment_session_id=session_id,\n", + " region=AWS_REGION,\n", + " agent_name=f\"pay-for-api-agent-{label.lower()}\",\n", + " network_preferences_config=network_preferences,\n", + " )\n", + " )\n", + " return Agent(\n", + " model=model,\n", + " tools=[http_request],\n", + " plugins=[plugin],\n", + " system_prompt=SYSTEM_PROMPT,\n", + " )\n", + "\n", + "\n", + "print(\"\u2705 Agent factory ready \u2014 \u00a78 calls build_agent() once per network run\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "10aa519f", + "metadata": {}, + "source": [ + "## 7. Ask the agents to buy a fact (local run)\n", + "\n", + "Run the EVM agent first, then the Solana agent. The plugin handles\n", + "402 \u2192 ProcessPayment \u2192 retry for any HTTP request the agent makes, on\n", + "whichever network the agent's instrument targets. Watch the logs: you\n", + "should see one `ProcessPayment` call per fact each agent buys.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0ada09c", + "metadata": {}, + "outputs": [], + "source": [ + "# One agent pattern, rebuilt per (provider, network). The plugin pins\n", + "# the (instrument, session, network preferences) triple, so each run\n", + "# is a fresh agent whose only job is to pay on that network via that\n", + "# provider.\n", + "#\n", + "# Privy wallets require a separate signing delegation, granted via the\n", + "# Privy Wallet Hub frontend (\u00a74.5e). Once delegation is granted, set\n", + "# PRIVY_DELEGATION_WIRED_UP = True (default) so \u00a77 spends from the Privy\n", + "# wallets too. Flip back to False to skip Privy rows during dev.\n", + "PRIVY_DELEGATION_WIRED_UP = True\n", + "\n", + "runs = [\n", + " {\n", + " \"label\": \"CDP EVM\",\n", + " \"provider\": \"CoinbaseCDP\",\n", + " \"network\": \"Base Sepolia\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_CDP_EVM,\n", + " \"session_id\": SESSION_ID_CDP,\n", + " \"network_preferences\": [\"base-sepolia\", \"solana-devnet\"],\n", + " \"topic\": \"space\",\n", + " },\n", + " {\n", + " \"label\": \"CDP SOLANA\",\n", + " \"provider\": \"CoinbaseCDP\",\n", + " \"network\": \"Solana Devnet\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_CDP_SOL,\n", + " \"session_id\": SESSION_ID_CDP,\n", + " \"network_preferences\": [\"solana-devnet\", \"base-sepolia\"],\n", + " \"topic\": \"oceans\",\n", + " },\n", + " {\n", + " \"label\": \"Privy EVM\",\n", + " \"provider\": \"StripePrivy\",\n", + " \"network\": \"Base Sepolia\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_PRIVY_EVM,\n", + " \"session_id\": SESSION_ID_PRIVY,\n", + " \"network_preferences\": [\"base-sepolia\", \"solana-devnet\"],\n", + " \"topic\": \"ai\",\n", + " },\n", + " {\n", + " \"label\": \"Privy SOLANA\",\n", + " \"provider\": \"StripePrivy\",\n", + " \"network\": \"Solana Devnet\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_PRIVY_SOL,\n", + " \"session_id\": SESSION_ID_PRIVY,\n", + " \"network_preferences\": [\"solana-devnet\", \"base-sepolia\"],\n", + " \"topic\": \"payments\",\n", + " },\n", + "]\n", + "\n", + "results = {}\n", + "for cfg in runs:\n", + " label = cfg[\"label\"]\n", + " if not cfg[\"instrument_id\"] or not cfg[\"session_id\"]:\n", + " print(f\"\\n\u2139\ufe0f Skipping {label} \u2014 no instrument/session configured\")\n", + " continue\n", + " if cfg[\"provider\"] == \"StripePrivy\" and not PRIVY_DELEGATION_WIRED_UP:\n", + " print(f\"\\n\u2139\ufe0f Skipping {label} \u2014 Privy user delegation not yet wired up\")\n", + " continue\n", + " print(f\"\\n\u2500\u2500 {label} run ({cfg['network']}) \u2500\u2500\")\n", + " agent = build_agent(\n", + " instrument_id=cfg[\"instrument_id\"],\n", + " session_id=cfg[\"session_id\"],\n", + " network_preferences=cfg[\"network_preferences\"],\n", + " label=label.replace(\" \", \"-\").lower(),\n", + " )\n", + "\n", + " # Retry on transient Bedrock throttling. Claude Sonnet 4.5 in\n", + " # us-west-2 occasionally returns ServiceUnavailableException\n", + " # (\"Too many connections\") when back-to-back agent runs stack up.\n", + " # A short exponential backoff handles the squeeze without failing\n", + " # the notebook.\n", + " import time\n", + " from botocore.exceptions import ClientError\n", + " attempt, max_attempts = 0, 4\n", + " while True:\n", + " try:\n", + " results[label] = agent(\n", + " f\"Get me one interesting fact about {cfg['topic']} from the seller \"\n", + " f\"at {SELLER_API_URL}. Tell me the total amount spent in USD at the end.\"\n", + " )\n", + " break\n", + " except ClientError as exc:\n", + " code = exc.response.get(\"Error\", {}).get(\"Code\", \"\")\n", + " if code not in (\"ServiceUnavailableException\", \"ThrottlingException\"):\n", + " raise\n", + " attempt += 1\n", + " if attempt >= max_attempts:\n", + " raise\n", + " wait = 2 ** attempt\n", + " print(f\" \u26a0\ufe0f Bedrock {code} \u2014 retry {attempt}/{max_attempts - 1} in {wait}s...\")\n", + " time.sleep(wait)\n", + " print(f\"\\n\u2500\u2500 {label} response \u2500\u2500\")\n", + " print(results[label])\n", + "\n", + " # Short pause between runs so the next agent doesn't pile onto the\n", + " # same Bedrock connection pool while the previous stream is closing.\n", + " time.sleep(2)\n", + "\n", + "# `result` retains the original single-run variable name so downstream\n", + "# cells (\u00a78 Runtime invoke) keep working.\n", + "result = results.get(\"CDP EVM\") or next(iter(results.values()), None)\n" + ] + }, + { + "cell_type": "markdown", + "id": "692d2e44", + "metadata": {}, + "source": [ + "## 8. Deploy the agent to AgentCore Runtime\n", + "\n", + "The agent we ran in \u00a77 works on a laptop. To run it as a managed service you\n", + "ship the same `Agent()` construction inside a container and deploy it to\n", + "**Amazon Bedrock AgentCore Runtime**. The `agent/` folder under this use case\n", + "has:\n", + "\n", + "- `agent/container/` \u2014 the FastAPI wrapper plus the same agent code\n", + "- `agent/cdk/` \u2014 a CDK stack that builds the image locally via Docker,\n", + " pushes it to the CDK bootstrap ECR, and provisions an AgentCore Runtime\n", + " pointing at it\n", + "\n", + "The stack also attaches a minimal IAM role: `bedrock:InvokeModel*` on the\n", + "Claude Sonnet 4.5 inference profile plus a handful of AgentCore Payments\n", + "data-plane ops the plugin needs (`ProcessPayment`, `GetPaymentSession`,\n", + "`GetPaymentInstrument`, `GetPaymentInstrumentBalance`,\n", + "`GetResourcePaymentToken`). Control-plane ops (`CreatePaymentManager`, etc.)\n", + "are *not* granted to the Runtime \u2014 those live with the operator.\n", + "\n", + "Running this section requires:\n", + "\n", + "- **AWS CDK v2** installed (`npm install -g aws-cdk`) \u2014 the only local tool needed;\n", + " the container image is built in **AWS CodeBuild** so you do not need\n", + " Docker on your laptop.\n", + "\n", + "> \u26a0\ufe0f **Cost notice:** This step provisions an Amazon ECR\n", + "> repository, an AWS CodeBuild build, an AgentCore Runtime, an\n", + "> AgentCore Memory resource, and the supporting CloudWatch log\n", + "> groups and X-Ray traces. CodeBuild billed-by-the-minute and the\n", + "> Runtime billed-by-the-invocation can add up if you leave the\n", + "> stack running. Run \u00a710 to tear everything down when you are done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f442937d", + "metadata": {}, + "outputs": [], + "source": [ + "# Deploy the agent runtime via test/integration/deploy-agent.sh.\n", + "# The script creates a CDK venv, installs CDK deps, bootstraps\n", + "# CDK if needed, builds the Docker image, and runs `cdk deploy`.\n", + "# We stream its stdout so the (often multi-minute) build isn't silent.\n", + "import json\n", + "import subprocess\n", + "from pathlib import Path\n", + "\n", + "HERE = Path(\".\").resolve()\n", + "AGENT_CDK_DIR = HERE / \"agent\" / \"cdk\"\n", + "DEPLOY = HERE / \"test\" / \"integration\" / \"deploy-agent.sh\"\n", + "\n", + "if not DEPLOY.exists():\n", + " raise FileNotFoundError(\n", + " f\"Expected {DEPLOY} \u2014 run this notebook from the \"\n", + " \"02-use-cases/01-pay-for-api directory.\"\n", + " )\n", + "\n", + "proc = subprocess.Popen(\n", + " [\"bash\", str(DEPLOY)],\n", + " cwd=str(HERE),\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " text=True,\n", + " bufsize=1,\n", + ")\n", + "for line in proc.stdout:\n", + " print(line, end=\"\")\n", + "rc = proc.wait()\n", + "if rc != 0:\n", + " raise RuntimeError(f\"deploy-agent.sh failed with exit code {rc}\")\n", + "\n", + "cdk_outputs = json.loads((AGENT_CDK_DIR / \"outputs.json\").read_text())\n", + "outputs = cdk_outputs[\"AgentCorePaymentsBuyerAgentStack\"]\n", + "AGENT_RUNTIME_ARN = outputs[\"AgentRuntimeArn\"]\n", + "AGENT_RUNTIME_ID = outputs[\"AgentRuntimeId\"]\n", + "print(\"\\n\u2705 Runtime deployed\")\n", + "print(f\" Runtime ARN: {AGENT_RUNTIME_ARN}\")\n", + "print(f\" Runtime ID: {AGENT_RUNTIME_ID}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-runtime-txn-search", + "metadata": {}, + "source": [ + "### Enable Transaction Search (one-time, console step)\n", + "\n", + "Before invoking the runtime, enable Transaction Search so the\n", + "**Observability** tab on the runtime page shows traces and span\n", + "details. This is a one-time setup per runtime.\n", + "\n", + "1. Open the **Amazon Bedrock AgentCore** console \u2192 **Runtime**.\n", + "2. Choose **`pay_for_api_agent_runtime`** in the list.\n", + "\n", + "
\n", + " \"Selecting\n", + "
\n", + "\n", + "3. Open the **Log deliveries and tracing** tab.\n", + "4. Enable **Transaction Search**.\n", + "5. Choose **Save**.\n", + "\n", + "
\n", + " \"Enable\n", + "
\n", + "\n", + "\n", + "After saving, the panel confirms Transaction Search is enabled:\n", + "\n", + "
\n", + " \"Transaction\n", + "
\n", + "\n", + "\n", + "> Transaction Search takes a few minutes to start indexing after you\n", + "> save. If the Observability tab shows no data immediately, wait a\n", + "> minute and refresh.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "139df648", + "metadata": {}, + "outputs": [], + "source": [ + "# Invoke the deployed runtime once per (provider, network) \u2014 same\n", + "# four-run matrix as \u00a77's local pattern. The container constructs the\n", + "# Strands Agent + plugin per invocation based on the payload we send,\n", + "# so we can target any wallet by picking different IDs.\n", + "#\n", + "# Privy wallets require a signing delegation granted via the Privy\n", + "# Wallet Hub frontend (\u00a74.5e). With delegation in place, the deployed\n", + "# runtime can call ProcessPayment against Privy instruments too.\n", + "import json as _json\n", + "import time as _time\n", + "\n", + "PRIVY_DELEGATION_WIRED_UP = True\n", + "\n", + "runtime_client = boto3.client(\"bedrock-agentcore\", region_name=AWS_REGION)\n", + "\n", + "runtime_runs = [\n", + " {\n", + " \"label\": \"CDP EVM\",\n", + " \"provider\": \"CoinbaseCDP\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_CDP_EVM,\n", + " \"session_id\": SESSION_ID_CDP,\n", + " \"network_preferences\": [\"base-sepolia\", \"solana-devnet\"],\n", + " \"topic\": \"space\",\n", + " },\n", + " {\n", + " \"label\": \"CDP SOLANA\",\n", + " \"provider\": \"CoinbaseCDP\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_CDP_SOL,\n", + " \"session_id\": SESSION_ID_CDP,\n", + " \"network_preferences\": [\"solana-devnet\", \"base-sepolia\"],\n", + " \"topic\": \"oceans\",\n", + " },\n", + " {\n", + " \"label\": \"Privy EVM\",\n", + " \"provider\": \"StripePrivy\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_PRIVY_EVM,\n", + " \"session_id\": SESSION_ID_PRIVY,\n", + " \"network_preferences\": [\"base-sepolia\", \"solana-devnet\"],\n", + " \"topic\": \"ai\",\n", + " },\n", + " {\n", + " \"label\": \"Privy SOLANA\",\n", + " \"provider\": \"StripePrivy\",\n", + " \"instrument_id\": PAYMENT_INSTRUMENT_ID_PRIVY_SOL,\n", + " \"session_id\": SESSION_ID_PRIVY,\n", + " \"network_preferences\": [\"solana-devnet\", \"base-sepolia\"],\n", + " \"topic\": \"payments\",\n", + " },\n", + "]\n", + "\n", + "remote_results = {}\n", + "for cfg in runtime_runs:\n", + " label = cfg[\"label\"]\n", + " if not cfg[\"instrument_id\"] or not cfg[\"session_id\"]:\n", + " print(f\"\\n\\u21b7 {label}: skipped (no instrument/session)\")\n", + " continue\n", + " if cfg[\"provider\"] == \"StripePrivy\" and not PRIVY_DELEGATION_WIRED_UP:\n", + " print(f\"\\n\\u21b7 {label}: skipped (Privy user delegation not wired up)\")\n", + " continue\n", + "\n", + " print(f\"\\n\\u2500\\u2500 {label} runtime invoke \\u2500\\u2500\")\n", + " payment_user_id = resolve_payment_user_id(cfg[\"instrument_id\"])\n", + "\n", + " resp = runtime_client.invoke_agent_runtime(\n", + " agentRuntimeArn=AGENT_RUNTIME_ARN,\n", + " qualifier=\"DEFAULT\",\n", + " payload=_json.dumps({\n", + " \"prompt\": (\n", + " f\"Get me one interesting fact about {cfg['topic']}. \"\n", + " \"Tell me the total amount spent in USD at the end.\"\n", + " ),\n", + " \"sellerUrl\": SELLER_API_URL,\n", + " \"managerArn\": MANAGER_ARN,\n", + " \"instrumentId\": cfg[\"instrument_id\"],\n", + " \"sessionId\": cfg[\"session_id\"],\n", + " \"paymentUserId\": payment_user_id,\n", + " \"networkPreferences\": cfg[\"network_preferences\"],\n", + " \"region\": AWS_REGION,\n", + " }).encode(),\n", + " )\n", + "\n", + " body = resp[\"response\"]\n", + " payload = (\n", + " b\"\".join(body.iter_chunks())\n", + " if hasattr(body, \"iter_chunks\")\n", + " else body.read()\n", + " )\n", + " remote_results[label] = _json.loads(payload)\n", + " response_text = remote_results[label].get(\"response\", remote_results[label])\n", + " print(response_text)\n", + "\n", + " # Short pause between invocations so the runtime doesn't queue the\n", + " # next call while the previous stream is still closing.\n", + " _time.sleep(2)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-runtime-observability", + "metadata": {}, + "source": [ + "### Inspect the runtime in the console\n", + "\n", + "After the runtime returns, open the AgentCore console to see the\n", + "underlying spans and logs.\n", + "\n", + "1. From the runtime detail page, choose **View dashboard** in the\n", + " **Observability** section.\n", + "\n", + "
\n", + " \"Observability\n", + "
\n", + "\n", + "2. The CloudWatch GenAI Observability dashboard opens. Open the\n", + " **Sessions** tab and choose the most recent **Session ID**.\n", + "\n", + "
\n", + " \"CloudWatch\n", + "
\n", + "\n", + "3. Choose the most recent **Trace ID** to explore the\n", + " `POST /invocations` events: model invocations, tool calls,\n", + " payment requirement (`402`), `ProcessPayment` span, and the\n", + " final retry that returns `200 OK`.\n", + "\n", + "
\n", + " \"Trace\n", + "
\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "103f8c67", + "metadata": {}, + "source": [ + "## 9. Inspect the data plane\n", + "\n", + "Once the agent has spent, walk through the read-only data-plane APIs so\n", + "you can see exactly what the service recorded. Everything here runs\n", + "against the **management** client \u2014 the agent's `ProcessPaymentRole` has\n", + "an explicit `Deny` on these, which is how we enforce the audit boundary.\n", + "\n", + "### 9.1 `GetPaymentSession` \u2014 session state and remaining budget\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25500749", + "metadata": {}, + "outputs": [], + "source": [ + "# Wrap the whole inspect call in a try/except so an ExpiredTokenException\n", + "# (STS session rolled over past its 1-hour lifetime) surfaces a clear\n", + "# re-run hint instead of a raw boto3 traceback.\n", + "from botocore.exceptions import ClientError\n", + "\n", + "try:\n", + " # GetPaymentSession carries the running budget state \u2014 one per session.\n", + " # We create one session per provider (\u00a75), so inspect both.\n", + " sessions_to_inspect = []\n", + " if SESSION_ID_CDP:\n", + " sessions_to_inspect.append((\"CDP\", SESSION_ID_CDP))\n", + " if SESSION_ID_PRIVY:\n", + " sessions_to_inspect.append((\"Privy\", SESSION_ID_PRIVY))\n", + "\n", + " for label, session_id in sessions_to_inspect:\n", + " resp = dp_client.get_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentSessionId=session_id,\n", + " userId=USER_ID,\n", + " )\n", + " s = resp.get(\"paymentSession\", {})\n", + " budget = s.get(\"limits\", {}).get(\"maxSpendAmount\", {})\n", + " avail = s.get(\"availableLimits\", {}).get(\"availableSpendAmount\", {})\n", + " budget_val = budget.get(\"value\", \"?\")\n", + " budget_cur = budget.get(\"currency\", \"\")\n", + " avail_val = avail.get(\"value\", \"?\")\n", + " avail_cur = avail.get(\"currency\", \"\")\n", + " print(f\"\\n\u2500\u2500 {label} session \u2500\u2500\")\n", + " print(f\" Session ID: {s.get('paymentSessionId', session_id)}\")\n", + " print(f\" Budget: {budget_val} {budget_cur}\")\n", + " print(f\" Remaining: {avail_val} {avail_cur}\")\n", + " print(f\" Expires in: {s.get('expiryTimeInMinutes', '?')} minutes\")\n", + " print(f\" Created at: {s.get('createdAt', '?')}\")\n", + "except ClientError as _exc:\n", + " if _exc.response.get(\"Error\", {}).get(\"Code\") == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a75.1 cell (Build session clients) to refresh \"\n", + " \"`dp_client` / `dp_agent_client`,\\n\"\n", + " \" then re-run THIS cell. (The fresh clients come up with \"\n", + " \"auto-refreshing creds\\n\"\n", + " \" via utils.assume_role, so this won't happen again in \"\n", + " \"this kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a75.1 (Build session clients), then re-run this cell.\"\n", + " ) from _exc\n", + " raise\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "7556649c", + "metadata": {}, + "source": [ + "### 9.2 `GetPaymentInstrumentBalance` \u2014 on-chain USDC balance\n", + "\n", + "`GetPaymentInstrumentBalance` asks AgentCore Payments for the on-chain\n", + "USDC balance of the instrument's wallet. AgentCore Payments maps the\n", + "instrument's network to a `chain`:\n", + "\n", + "| `CryptoWalletNetwork` | `chain` (`BlockchainChainId`) |\n", + "|-----------------------|-------------------------------|\n", + "| `ETHEREUM` | `BASE_SEPOLIA` (test) / `BASE` |\n", + "| `SOLANA` | `SOLANA_DEVNET` / `SOLANA` |\n", + "\n", + "Token is always `USDC` today (the only value the `InstrumentBalanceToken`\n", + "enum accepts). The response's `tokenBalance.amount` is the atomic amount\n", + "(6 decimals for USDC).\n", + "\n", + "> Higher-level SDK helpers may return a shorter `{amount, currency}`\n", + "> shape. The direct boto3 call this notebook uses returns the fuller\n", + "> `tokenBalance` structure \u2014 both are correct, prefer the SDK helper\n", + "> when available.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f6f7b09", + "metadata": {}, + "outputs": [], + "source": [ + "# Wrap the whole inspect call in a try/except so an ExpiredTokenException\n", + "# (STS session rolled over past its 1-hour lifetime) surfaces a clear\n", + "# re-run hint instead of a raw boto3 traceback.\n", + "from botocore.exceptions import ClientError\n", + "\n", + "try:\n", + " # GetPaymentInstrumentBalance needs (paymentManagerArn, paymentConnectorId,\n", + " # paymentInstrumentId, chain, token). Chain is an enum \u2014 BASE_SEPOLIA or\n", + " # SOLANA_DEVNET here; token is USDC across the board. We loop over every\n", + " # (provider, network) instrument that actually got created.\n", + " wallets = [\n", + " (\"CDP EVM\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_EVM, WALLET_ADDRESS_CDP_EVM, \"BASE_SEPOLIA\"),\n", + " (\"CDP SOLANA\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_SOL, WALLET_ADDRESS_CDP_SOL, \"SOLANA_DEVNET\"),\n", + " (\"Privy EVM\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_EVM, WALLET_ADDRESS_PRIVY_EVM, \"BASE_SEPOLIA\"),\n", + " (\"Privy SOLANA\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_SOL, WALLET_ADDRESS_PRIVY_SOL, \"SOLANA_DEVNET\"),\n", + " ]\n", + "\n", + " for label, connector_id, instrument_id, wallet_address, chain in wallets:\n", + " if not instrument_id or not connector_id:\n", + " print(f\"\\n\u21b7 {label}: skipped (no instrument)\")\n", + " continue\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " resp = dp_client.get_payment_instrument_balance(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=connector_id,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=payment_user_id,\n", + " chain=chain,\n", + " token=\"USDC\",\n", + " )\n", + " # Convert an atomic amount (the smallest indivisible unit a token uses on chain \u2014 for USDC, one atomic unit is 0.000001 USDC) to a human-readable token amount.\n", + " # `amount` is a string of base units (e.g. \"19990000\"); divide\n", + " # by 10**decimals to get USDC, e.g. 19.99.\n", + " bal = resp.get(\"tokenBalance\", {})\n", + " amount = int(bal.get(\"amount\", \"0\")) / (10 ** int(bal.get(\"decimals\", 6)))\n", + " print(f\"\\n\u2500\u2500 {label} ({chain}) \u2500\u2500\")\n", + " print(f\" Wallet: {wallet_address}\")\n", + " print(f\" Balance: {amount:.6f} {bal.get('token', 'USDC')}\")\n", + "except ClientError as _exc:\n", + " if _exc.response.get(\"Error\", {}).get(\"Code\") == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a75.1 cell (Build session clients) to refresh \"\n", + " \"`dp_client` / `dp_agent_client`,\\n\"\n", + " \" then re-run THIS cell. (The fresh clients come up with \"\n", + " \"auto-refreshing creds\\n\"\n", + " \" via utils.assume_role, so this won't happen again in \"\n", + " \"this kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a75.1 (Build session clients), then re-run this cell.\"\n", + " ) from _exc\n", + " raise\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "d329615a", + "metadata": {}, + "source": [ + "### 9.3 `ListPaymentInstruments` \u2014 the instruments under this manager\n", + "\n", + "`ListPaymentInstruments` enumerates every instrument the caller is\n", + "authorised to see under a given `paymentManagerArn`. Filters:\n", + "\n", + "- `paymentConnectorId` (optional) \u2014 narrow to a single connector.\n", + "- `userId` (header, required in practice \u2014 forward the vendor-assigned\n", + " userId from \u00a74.5's instrument summary rather than USER_ID) \u2014 narrow\n", + " to a single vendor-level user.\n", + "\n", + "The response is a list of `PaymentInstrumentSummary` objects. Note the\n", + "`userId` on each summary \u2014 for Coinbase wallets that is the CDP end-user\n", + "UUID the service assigned, which is what you pass to every downstream op.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8729026c", + "metadata": {}, + "outputs": [], + "source": [ + "# Wrap the whole inspect call in a try/except so an ExpiredTokenException\n", + "# (STS session rolled over past its 1-hour lifetime) surfaces a clear\n", + "# re-run hint instead of a raw boto3 traceback.\n", + "from botocore.exceptions import ClientError\n", + "\n", + "try:\n", + " # ListPaymentInstruments enumerates every instrument under a Manager.\n", + " # The summary is thin \u2014 no walletAddress/network \u2014 so we hydrate each\n", + " # entry with GetPaymentInstrument. We also pass paymentConnectorId so\n", + " # each provider's list is scoped to only its own instruments.\n", + " for label, connector_id, instrument_id in (\n", + " (\"CDP\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_EVM),\n", + " (\"Privy\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_EVM),\n", + " ):\n", + " if not instrument_id or not connector_id:\n", + " print(f\"\\n\\u21b7 {label}: skipped (no instrument)\")\n", + " continue\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " resp = dp_client.list_payment_instruments(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=connector_id,\n", + " userId=payment_user_id,\n", + " maxResults=20,\n", + " )\n", + " summaries = resp.get(\"paymentInstruments\", [])\n", + " print(f\"\\n\\u2500\\u2500 {label} instruments ({len(summaries)}) \\u2500\\u2500\")\n", + " for i, summary in enumerate(summaries, start=1):\n", + " # Hydrate each entry to pull walletAddress + network.\n", + " full = dp_client.get_payment_instrument(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentInstrumentId=summary[\"paymentInstrumentId\"],\n", + " userId=payment_user_id,\n", + " )[\"paymentInstrument\"]\n", + " crypto = full.get(\"paymentInstrumentDetails\", {}).get(\"embeddedCryptoWallet\", {})\n", + " address = crypto.get(\"walletAddress\", \"(no address)\")\n", + " network = crypto.get(\"network\", \"?\")\n", + " status = full.get(\"status\", \"?\")\n", + " print(\n", + " f\" [{i}] {full['paymentInstrumentId']} ({network}, {status})\\n\"\n", + " f\" Wallet: {address}\"\n", + " )\n", + "except ClientError as _exc:\n", + " if _exc.response.get(\"Error\", {}).get(\"Code\") == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a75.1 cell (Build session clients) to refresh \"\n", + " \"`dp_client` / `dp_agent_client`,\\n\"\n", + " \" then re-run THIS cell. (The fresh clients come up with \"\n", + " \"auto-refreshing creds\\n\"\n", + " \" via utils.assume_role, so this won't happen again in \"\n", + " \"this kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a75.1 (Build session clients), then re-run this cell.\"\n", + " ) from _exc\n", + " raise\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "7f0ceb4e", + "metadata": {}, + "source": [ + "### 9.4 `ListPaymentSessions` \u2014 the sessions under this manager\n", + "\n", + "`ListPaymentSessions` is the same idea for sessions. Scoped by\n", + "`paymentManagerArn` + optional `userId` header. The summary includes\n", + "`expiryTimeInMinutes` and the `createdAt` / `updatedAt` timestamps so you\n", + "can build an audit view without fetching each session individually.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b96ae742", + "metadata": {}, + "outputs": [], + "source": [ + "# Wrap the whole inspect call in a try/except so an ExpiredTokenException\n", + "# (STS session rolled over past its 1-hour lifetime) surfaces a clear\n", + "# re-run hint instead of a raw boto3 traceback.\n", + "from botocore.exceptions import ClientError\n", + "\n", + "try:\n", + " # ListPaymentSessions is the same idea for sessions. The summary omits\n", + " # `limits` and `availableLimits` so we hydrate via GetPaymentSession for\n", + " # an at-a-glance audit view.\n", + " for label, instrument_id in (\n", + " (\"CDP\", PAYMENT_INSTRUMENT_ID_CDP_EVM),\n", + " (\"Privy\", PAYMENT_INSTRUMENT_ID_PRIVY_EVM),\n", + " ):\n", + " if not instrument_id:\n", + " print(f\"\\n\\u21b7 {label}: skipped (no instrument)\")\n", + " continue\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " resp = dp_client.list_payment_sessions(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " userId=payment_user_id,\n", + " maxResults=20,\n", + " )\n", + " summaries = resp.get(\"paymentSessions\", [])\n", + " print(f\"\\n\\u2500\\u2500 {label} sessions ({len(summaries)}) \\u2500\\u2500\")\n", + " for i, summary in enumerate(summaries, start=1):\n", + " full = dp_client.get_payment_session(\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentSessionId=summary[\"paymentSessionId\"],\n", + " userId=payment_user_id,\n", + " )[\"paymentSession\"]\n", + " budget = full.get(\"limits\", {}).get(\"maxSpendAmount\", {})\n", + " avail = full.get(\"availableLimits\", {}).get(\"availableSpendAmount\", {})\n", + " print(\n", + " f\" [{i}] {full['paymentSessionId']}\\n\"\n", + " f\" Budget: {budget.get('value', '?')} {budget.get('currency', '')}\\n\"\n", + " f\" Remaining: {avail.get('value', '?')} {avail.get('currency', '')}\\n\"\n", + " f\" Expires: {full.get('expiryTimeInMinutes', '?')} min\\n\"\n", + " f\" Created: {full.get('createdAt', '?')}\"\n", + " )\n", + "except ClientError as _exc:\n", + " if _exc.response.get(\"Error\", {}).get(\"Code\") == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a75.1 cell (Build session clients) to refresh \"\n", + " \"`dp_client` / `dp_agent_client`,\\n\"\n", + " \" then re-run THIS cell. (The fresh clients come up with \"\n", + " \"auto-refreshing creds\\n\"\n", + " \" via utils.assume_role, so this won't happen again in \"\n", + " \"this kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a75.1 (Build session clients), then re-run this cell.\"\n", + " ) from _exc\n", + " raise\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "3ebf87b2", + "metadata": {}, + "source": [ + "## 10. Cleanup\n", + "\n", + "This use case provisioned the following billable AWS resources. Run\n", + "the cells in this section in order to remove them and stop incurring\n", + "charges:\n", + "\n", + "| Resource | Created in | Cleaned up by |\n", + "|----------|------------|---------------|\n", + "| Fun Facts seller (Amazon API Gateway HTTP API + AWS Lambda function) | \u00a73 | \u00a710 *Tear down the seller stack* |\n", + "| AgentCore Runtime + Amazon ECR repository + AWS CodeBuild project | \u00a78 | \u00a710 *Tear down the agent runtime* |\n", + "| AgentCore Memory resource | \u00a78 | \u00a710 *Tear down the agent runtime* (deleted with the agent stack) |\n", + "| Payment Manager, Connectors, Instruments, Sessions, Credential Providers | \u00a74 + \u00a75 | \u00a710 *Tear down AgentCore Payments resources* |\n", + "| CloudWatch log groups + X-Ray traces | populated by \u00a77 + \u00a78 | retained \u2014 delete by hand from the console if you need to clear historical logs |\n", + "| Four IAM roles (`AgentCorePayments*Role`) | `setup-roles.sh` in \u00a72 | retained \u2014 these have no standing cost. Run `aws iam delete-role` on each if you want a clean slate |\n", + "\n", + "### Revoke the payment session\n", + "\n", + "`DeletePaymentSession` hard-deletes the session server-side. The record\n", + "is removed permanently; there is no undelete. It is the revoke path\n", + "for a session you no longer want the agent to be able to spend against.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e27ab060", + "metadata": {}, + "outputs": [], + "source": [ + "import botocore.exceptions\n", + "\n", + "\n", + "def _safe_delete(fn, label: str, **kwargs) -> None:\n", + " try:\n", + " fn(**kwargs)\n", + " print(f\" \u2705 Deleted: {label}\")\n", + " except botocore.exceptions.ClientError as exc:\n", + " code = exc.response[\"Error\"][\"Code\"]\n", + " msg = exc.response[\"Error\"].get(\"Message\", \"\")\n", + " # Some cleanup paths return AccessDenied with \"not found\" in the\n", + " # message when the parent resource was already torn down. Treat\n", + " # it the same as ResourceNotFoundException \u2014 benign no-op.\n", + " if code == \"ResourceNotFoundException\" or (\n", + " code == \"AccessDeniedException\" and \"not found\" in msg.lower()\n", + " ):\n", + " print(f\" \u26a0\ufe0f Not found: {label}\")\n", + " elif code == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a74.1 cell (Assume roles + build clients) to \"\n", + " \"refresh `dp_client_mgmt`,\\n\"\n", + " \" then re-run THIS cell. (Fresh clients come up with \"\n", + " \"auto-refreshing creds via\\n\"\n", + " \" utils.assume_role, so this won't happen again in this \"\n", + " \"kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a74.1 (Assume roles + build clients), then re-run this cell.\"\n", + " ) from exc\n", + " else:\n", + " raise\n", + "\n", + "\n", + "if not MANAGER_ARN:\n", + " print(\"\u2139\ufe0f Nothing to tear down \u2014 MANAGER_ARN is unset.\")\n", + "else:\n", + " # 1. Soft-delete every instrument first \u2014 Manager / Connector cleanup\n", + " # requires no ACTIVE instruments.\n", + " for label, connector_id, instrument_id in (\n", + " (\"CDP EVM\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_EVM),\n", + " (\"CDP SOLANA\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_SOL),\n", + " (\"Privy EVM\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_EVM),\n", + " (\"Privy SOLANA\",CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_SOL),\n", + " ):\n", + " if not instrument_id or not connector_id:\n", + " continue\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " _safe_delete(\n", + " dp_client_mgmt.delete_payment_instrument,\n", + " f\"Instrument {label} ({instrument_id})\",\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=connector_id,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=payment_user_id,\n", + " )\n", + "\n", + " # 2. Connectors.\n", + " for label, connector_id in (\n", + " (\"CDP\", CONNECTOR_ID_CDP),\n", + " (\"Privy\", CONNECTOR_ID_PRIVY),\n", + " ):\n", + " if not connector_id:\n", + " continue\n", + " _safe_delete(\n", + " cp_client.delete_payment_connector,\n", + " f\"{label} connector {connector_id}\",\n", + " paymentManagerId=MANAGER_ID,\n", + " paymentConnectorId=connector_id,\n", + " clientToken=_client_token(),\n", + " )\n", + "\n", + " # 3. Manager.\n", + " _safe_delete(\n", + " cp_client.delete_payment_manager,\n", + " f\"Manager {MANAGER_ID}\",\n", + " paymentManagerId=MANAGER_ID,\n", + " clientToken=_client_token(),\n", + " )\n", + "\n", + " # 4. Credential Providers.\n", + " for label, name in (\n", + " (\"CDP\", os.environ.get(\"CRED_PROVIDER_NAME_CDP\", \"\")),\n", + " (\"Privy\", os.environ.get(\"CRED_PROVIDER_NAME_PRIVY\", \"\")),\n", + " ):\n", + " if not name:\n", + " continue\n", + " _safe_delete(\n", + " cred_client.delete_payment_credential_provider,\n", + " f\"{label} Credential Provider {name}\",\n", + " name=name,\n", + " )\n", + " # Clear the just-cleaned-up IDs from .env so a subsequent notebook\n", + " # run doesn't try to reuse them.\n", + " from utils import write_env_updates\n", + " write_env_updates({\n", + " \"MANAGER_ID\": \"\", \"MANAGER_ARN\": \"\",\n", + " \"CRED_PROVIDER_NAME_CDP\": \"\", \"CREDENTIAL_PROVIDER_ARN_CDP\": \"\",\n", + " \"CRED_PROVIDER_NAME_PRIVY\": \"\", \"CREDENTIAL_PROVIDER_ARN_PRIVY\": \"\",\n", + " \"CONNECTOR_ID_CDP\": \"\", \"CONNECTOR_ID_PRIVY\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_CDP_EVM\": \"\", \"WALLET_ADDRESS_CDP_EVM\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_CDP_SOL\": \"\", \"WALLET_ADDRESS_CDP_SOL\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_PRIVY_EVM\": \"\", \"WALLET_ADDRESS_PRIVY_EVM\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_PRIVY_SOL\": \"\", \"WALLET_ADDRESS_PRIVY_SOL\": \"\",\n", + " \"SESSION_ID_CDP\": \"\", \"SESSION_ID_PRIVY\": \"\",\n", + " })\n", + " print(\"\\n\u2705 AgentCore Payments resources cleaned up.\")\n", + " print(\"\ud83d\udcbe .env cleared of Manager/Connector/Instrument/Session IDs.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-36", + "metadata": {}, + "source": [ + "### Tear down the seller stack\n", + "\n", + "> \u26a0\ufe0f **WARNING:** Run only when you are done with the use case. Destroying the\n", + "> seller invalidates `SELLER_API_URL` in your `.env`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-36a", + "metadata": {}, + "outputs": [], + "source": [ + "!bash test/integration/destroy-seller.sh\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-36b", + "metadata": {}, + "source": [ + "### Tear down the agent runtime\n", + "\n", + "Only if you deployed the agent to AgentCore Runtime in \u00a78. Skip this\n", + "if you ran the agent locally only.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-36c", + "metadata": {}, + "outputs": [], + "source": [ + "!bash test/integration/destroy-agent.sh\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-36d", + "metadata": {}, + "source": [ + "### Tear down AgentCore Payments resources\n", + "\n", + "If you ran the setup in \u00a74 and want to delete everything you created,\n", + "run the cell below. Order matters: soft-delete both Instruments \u2192\n", + "Connector \u2192 Manager \u2192 Credential Provider.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-37", + "metadata": {}, + "outputs": [], + "source": [ + "import botocore.exceptions\n", + "\n", + "\n", + "def _safe_delete(fn, label: str, **kwargs) -> None:\n", + " try:\n", + " fn(**kwargs)\n", + " print(f\" \u2705 Deleted: {label}\")\n", + " except botocore.exceptions.ClientError as exc:\n", + " code = exc.response[\"Error\"][\"Code\"]\n", + " msg = exc.response[\"Error\"].get(\"Message\", \"\")\n", + " # Some cleanup paths return AccessDenied with \"not found\" in the\n", + " # message when the parent resource was already torn down. Treat\n", + " # it the same as ResourceNotFoundException \u2014 benign no-op.\n", + " if code == \"ResourceNotFoundException\" or (\n", + " code == \"AccessDeniedException\" and \"not found\" in msg.lower()\n", + " ):\n", + " print(f\" \u26a0\ufe0f Not found: {label}\")\n", + " elif code == \"ExpiredTokenException\":\n", + " print(\n", + " \"\u23f3 STS session credentials expired.\\n\"\n", + " \" Re-run the \u00a74.1 cell (Assume roles + build clients) to \"\n", + " \"refresh `dp_client_mgmt`,\\n\"\n", + " \" then re-run THIS cell. (Fresh clients come up with \"\n", + " \"auto-refreshing creds via\\n\"\n", + " \" utils.assume_role, so this won't happen again in this \"\n", + " \"kernel.)\"\n", + " )\n", + " raise SystemExit(\n", + " \"Re-run \u00a74.1 (Assume roles + build clients), then re-run this cell.\"\n", + " ) from exc\n", + " else:\n", + " raise\n", + "\n", + "\n", + "if not MANAGER_ARN:\n", + " print(\"\u2139\ufe0f Nothing to tear down \u2014 MANAGER_ARN is unset.\")\n", + "else:\n", + " # 1. Soft-delete every instrument first \u2014 Manager / Connector\n", + " # cleanup requires no ACTIVE instruments. If the Manager itself\n", + " # is already gone (e.g. a prior cleanup run), skip gracefully.\n", + " for label, connector_id, instrument_id in (\n", + " (\"CDP EVM\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_EVM),\n", + " (\"CDP SOLANA\", CONNECTOR_ID_CDP, PAYMENT_INSTRUMENT_ID_CDP_SOL),\n", + " (\"Privy EVM\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_EVM),\n", + " (\"Privy SOLANA\", CONNECTOR_ID_PRIVY, PAYMENT_INSTRUMENT_ID_PRIVY_SOL),\n", + " ):\n", + " if not instrument_id or not connector_id:\n", + " continue\n", + " try:\n", + " payment_user_id = resolve_payment_user_id(instrument_id)\n", + " except botocore.exceptions.ClientError as exc:\n", + " code = exc.response.get(\"Error\", {}).get(\"Code\", \"\")\n", + " msg = exc.response.get(\"Error\", {}).get(\"Message\", \"\")\n", + " # The upstream Manager might have been torn down in a prior\n", + " # run \u2014 the service returns \"AccessDenied: Payment manager\n", + " # not found\" or \"ResourceNotFoundException\" depending on the\n", + " # path. Either way, nothing to clean up for this row.\n", + " if code in (\"AccessDeniedException\", \"ResourceNotFoundException\") \\\n", + " or \"not found\" in msg.lower():\n", + " print(f\" \u26a0\ufe0f Skipping {label} \u2014 already deleted ({code})\")\n", + " continue\n", + " raise\n", + " _safe_delete(\n", + " dp_client_mgmt.delete_payment_instrument,\n", + " f\"Instrument {label} ({instrument_id})\",\n", + " paymentManagerArn=MANAGER_ARN,\n", + " paymentConnectorId=connector_id,\n", + " paymentInstrumentId=instrument_id,\n", + " userId=payment_user_id,\n", + " )\n", + "\n", + " # 2. Connectors \u2192 Manager \u2192 Credential Providers.\n", + " for label, connector_id in (\n", + " (\"CDP\", CONNECTOR_ID_CDP),\n", + " (\"Privy\", CONNECTOR_ID_PRIVY),\n", + " ):\n", + " if not connector_id:\n", + " continue\n", + " _safe_delete(\n", + " cp_client.delete_payment_connector,\n", + " f\"{label} connector ({connector_id})\",\n", + " paymentManagerId=MANAGER_ID,\n", + " paymentConnectorId=connector_id,\n", + " clientToken=_client_token(),\n", + " )\n", + "\n", + " _safe_delete(\n", + " cp_client.delete_payment_manager,\n", + " f\"Manager {MANAGER_ID}\",\n", + " paymentManagerId=MANAGER_ID,\n", + " clientToken=_client_token(),\n", + " )\n", + "\n", + " for label, name in (\n", + " (\"CDP\", CRED_PROVIDER_NAME_CDP),\n", + " (\"Privy\", CRED_PROVIDER_NAME_PRIVY),\n", + " ):\n", + " if not name:\n", + " continue\n", + " _safe_delete(\n", + " cred_client.delete_payment_credential_provider,\n", + " f\"{label} credential provider ({name})\",\n", + " name=name,\n", + " )\n", + " # Clear the just-cleaned-up IDs from .env so a subsequent notebook\n", + " # run doesn't try to reuse them.\n", + " from utils import write_env_updates\n", + " write_env_updates({\n", + " \"MANAGER_ID\": \"\", \"MANAGER_ARN\": \"\",\n", + " \"CRED_PROVIDER_NAME_CDP\": \"\", \"CREDENTIAL_PROVIDER_ARN_CDP\": \"\",\n", + " \"CRED_PROVIDER_NAME_PRIVY\": \"\", \"CREDENTIAL_PROVIDER_ARN_PRIVY\": \"\",\n", + " \"CONNECTOR_ID_CDP\": \"\", \"CONNECTOR_ID_PRIVY\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_CDP_EVM\": \"\", \"WALLET_ADDRESS_CDP_EVM\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_CDP_SOL\": \"\", \"WALLET_ADDRESS_CDP_SOL\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_PRIVY_EVM\": \"\", \"WALLET_ADDRESS_PRIVY_EVM\": \"\",\n", + " \"PAYMENT_INSTRUMENT_ID_PRIVY_SOL\": \"\", \"WALLET_ADDRESS_PRIVY_SOL\": \"\",\n", + " \"SESSION_ID_CDP\": \"\", \"SESSION_ID_PRIVY\": \"\",\n", + " })\n", + " print(\"\\n\u2705 AgentCore Payments resources cleaned up.\")\n", + " print(\"\ud83d\udcbe .env cleared of Manager/Connector/Instrument/Session IDs.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-36e", + "metadata": {}, + "source": [ + "### Remove local build artifacts\n", + "\n", + "`cdk deploy` leaves behind local artifacts: `.venv/` (the Python\n", + "environment each CDK app creates for itself), `cdk.out/` (synthesized\n", + "templates), `__pycache__/`, and `outputs.json`. The seller Lambda\n", + "leaves a hefty `seller/lambda/node_modules/` from `npm install`\n", + "(x402 facilitator deps). The Privy Wallet Hub frontend (\u00a74.5e) is\n", + "cloned at runtime, so the entire `privy-delegation/` folder gets\n", + "removed here \u2014 the next notebook run will re-clone it. None of these\n", + "hold cloud state, so clearing them is safe and makes the next run\n", + "start fresh.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-37b", + "metadata": {}, + "outputs": [], + "source": [ + "import pathlib\n", + "import shutil\n", + "\n", + "ROOT = pathlib.Path(\".\").resolve()\n", + "\n", + "# Dirs live under seller/cdk/ and agent/cdk/ (+ a few scattered\n", + "# __pycache__ directories from helper imports). We target exact paths\n", + "# rather than globbing so we never touch source trees.\n", + "targets = [\n", + " ROOT / \"seller\" / \"cdk\" / \".venv\",\n", + " ROOT / \"seller\" / \"cdk\" / \"cdk.out\",\n", + " ROOT / \"seller\" / \"cdk\" / \"__pycache__\",\n", + " ROOT / \"seller\" / \"cdk\" / \"outputs.json\",\n", + " # Seller Lambda \u2014 npm install pulls down the x402 facilitator\n", + " # deps (~16k files). Gitignored, but local-only weight on disk.\n", + " ROOT / \"seller\" / \"lambda\" / \"node_modules\",\n", + " ROOT / \"seller\" / \"lambda\" / \"package-lock.json\",\n", + " ROOT / \"agent\" / \"cdk\" / \".venv\",\n", + " ROOT / \"agent\" / \"cdk\" / \"cdk.out\",\n", + " ROOT / \"agent\" / \"cdk\" / \"__pycache__\",\n", + " ROOT / \"agent\" / \"cdk\" / \"outputs.json\",\n", + " ROOT / \"__pycache__\",\n", + " ROOT / \"test\" / \"integration\" / \"__pycache__\",\n", + " # Privy Wallet Hub frontend (\u00a74.5e). The clone cell pulls a fresh\n", + " # copy of privy-io/aws-agentcore-sdk on each notebook run, so it\n", + " # is safe to delete the entire folder here \u2014 anything in there is\n", + " # either a vendored upstream tree or runtime build output.\n", + " ROOT / \"privy-delegation\",\n", + "]\n", + "\n", + "for path in targets:\n", + " if not path.exists():\n", + " print(f\" \u21b7 skip (absent): {path.relative_to(ROOT)}\")\n", + " continue\n", + " if path.is_dir():\n", + " shutil.rmtree(path)\n", + " else:\n", + " path.unlink()\n", + " print(f\" \ud83d\uddd1\ufe0f removed: {path.relative_to(ROOT)}\")\n", + "\n", + "print(\"\\n\u2705 Local CDK artifacts removed.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-38", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "Go deeper with the public AgentCore Payments documentation:\n", + "\n", + "- [AgentCore Payments overview](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html)\n", + "- [How it works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html)\n", + "- [Core concepts](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-concepts.html)\n", + "- [Process a payment](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-process-payment.html) \u2014 plugin reference, interrupt contract, network preferences\n", + "- [Connect to Bazaar](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-connect-bazaar.html) \u2014 make the seller discoverable through the AgentCore Registry\n", + "- [Agents that transact (announcement blog)](https://aws.amazon.com/blogs/machine-learning/agents-that-transact-introducing-amazon-bedrock-agentcore-payments-built-with-coinbase-and-stripe/)\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-39", + "metadata": {}, + "source": [ + "# Congratulations!\n", + "\n", + "You have built an agent that autonomously pays for metered access to an HTTP\n", + "API using **Amazon Bedrock AgentCore Payments** \u2014 first as a local Strands\n", + "agent, then packaged into a container and deployed to **AgentCore Runtime**.\n", + "\n", + "Here is what we covered:\n", + "\n", + "* **Self-contained setup** \u2014 the notebook provisioned a full AgentCore\n", + " Payments stack inline (Credential Provider \u2192 Manager \u2192 Connector \u2192\n", + " Instrument \u2192 Session) without requiring any pre-existing infrastructure\n", + "* **CDK seller stack** \u2014 a minimal API Gateway + Lambda that charges\n", + " $0.01 per call\n", + "* **IAM role separation** \u2014 `ManagementRole` creates sessions;\n", + " `ProcessPaymentRole` signs payments (enforced by IAM `Deny`, not\n", + " documentation)\n", + "* **Local agent with the plugin** \u2014 one Strands agent, one\n", + " `http_request` tool, `AgentCorePaymentsPlugin` handling 402 \u2192\n", + " ProcessPayment \u2192 retry automatically\n", + "* **Same agent in Runtime** \u2014 identical agent code wrapped in a FastAPI\n", + " container and deployed via CDK (`agent/cdk/`). The notebook invoked\n", + " the deployed runtime with the same prompt it used locally.\n", + "* **Vendor-rooted identity** \u2014 every data-plane op runs under\n", + " `paymentInstrument.userId`, the CDP UUID or Privy DID the service\n", + " assigned at Create time. No tenant mapping, no DynamoDB.\n", + "* **Budget enforcement** \u2014 `maxSpendAmount` set by the operator before\n", + " the agent runs\n", + "* **Spend verification** \u2014 `GetPaymentSession` confirms exactly what the\n", + " agent spent\n", + "\n", + "**Ideas to extend this use case:**\n", + "\n", + "* Add streaming responses from the Runtime (`InvokeAgentRuntime` supports\n", + " chunked output) so the UI sees tokens as they arrive\n", + "* Attach conversation memory via `AgentCoreMemorySessionManager` so the\n", + " agent remembers prior topics\n", + "* Swap the static Fun Facts data for a real upstream \u2014 Bedrock summarizer,\n", + " third-party feed, or AgentCore Registry lookup\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/requirements.txt b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/requirements.txt new file mode 100644 index 000000000..7081a83de --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/requirements.txt @@ -0,0 +1,7 @@ +strands-agents>=1.39.0 +strands-agents-tools>=0.5.0 +bedrock-agentcore>=1.9.0 +boto3>=1.42.0 +botocore>=1.42.0 +requests>=2.32.0 +python-dotenv>=1.0.0 diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/app.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/app.py new file mode 100644 index 000000000..8b09180f5 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/app.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""CDK app entry point for the Pay for API — Fun Facts seller stack.""" + +import os + +import aws_cdk as cdk + +from seller_stack import AgentCorePaymentsFunFactsSellerStack + +app = cdk.App() + +# Region comes from the usual CDK resolution order: +# CDK_DEFAULT_REGION → AWS_REGION → AWS CLI profile region. +# We default to us-west-2 to match the default AgentCore Payments region. +env = cdk.Environment( + account=os.environ.get("CDK_DEFAULT_ACCOUNT"), + region=os.environ.get( + "CDK_DEFAULT_REGION", os.environ.get("AWS_REGION", "us-west-2") + ), +) + +AgentCorePaymentsFunFactsSellerStack( + app, + "AgentCorePaymentsFunFactsSellerStack", + env=env, + description="AgentCore Payments sample — Fun Facts x402 seller (pay per API call)", +) + +app.synth() diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/cdk.json b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/cdk.json new file mode 100644 index 000000000..25c9226f8 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/cdk.json @@ -0,0 +1,20 @@ +{ + "app": "python3 app.py", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, + "@aws-cdk/aws-lambda:recognizeVersionProps": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true + } +} diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/requirements.txt b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/requirements.txt new file mode 100644 index 000000000..ae4eafb93 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/requirements.txt @@ -0,0 +1,2 @@ +aws-cdk-lib>=2.140.0 +constructs>=10.3.0,<11.0.0 diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/seller_stack.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/seller_stack.py new file mode 100644 index 000000000..b71ba024f --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/cdk/seller_stack.py @@ -0,0 +1,155 @@ +"""Fun Facts seller CDK stack. + +Mirrors the agentcore-payments house-standard seller pattern +(backend/lambdas/sellers/crypto-price): + + - Node.js 20 ARM64 AWS Lambda function with pre-installed + ``node_modules`` packaged into the asset (the deploy script runs + ``npm install`` before ``cdk deploy``). + - Two env vars for payout — ``SELLER_WALLET_ADDRESS`` (EVM / Base + Sepolia) and ``SELLER_SOLANA_WALLET_ADDRESS`` (Solana / Devnet). Both + are forwarded by the x402 seller library into the ``accepts`` array on + each 402 response. Set one, both, or neither; the Lambda emits one + ``accepts`` entry per configured network. + - ``X402_FACILITATOR_URL`` — override to point at a private facilitator. + Defaults to the public x402.org facilitator. + - One route: ``GET /facts`` behind the x402 payment middleware, plus + public ``GET /`` and ``GET /health`` for sanity checks. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from aws_cdk import ( + CfnOutput, + Duration, + Stack, +) +from aws_cdk import aws_apigatewayv2 as apigwv2 +from aws_cdk import aws_apigatewayv2_integrations as apigwv2_integrations +from aws_cdk import aws_lambda as _lambda +from aws_cdk import aws_logs as logs +from constructs import Construct + +LAMBDA_CODE_DIR = str(Path(__file__).resolve().parent.parent / "lambda") + + +class AgentCorePaymentsFunFactsSellerStack(Stack): + """A minimal x402 seller: HTTP API → Node.js Lambda.""" + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # ── Seller config ──────────────────────────────────────────── + # Override via CDK context (`cdk deploy -c seller_wallet=0x…`) or + # environment variables at deploy time. Both networks are optional; + # if neither is set the Lambda still runs but the facilitator will + # reject payment proofs. Set at least one. + # + # Defaults to "WALLET_NOT_CONFIGURED" (mirrors seller/lambda/index.js) + # so an unset wallet shows up as a clearly invalid placeholder + # rather than an empty string. + evm_wallet = ( + self.node.try_get_context("seller_wallet") + or os.environ.get("SELLER_WALLET_ADDRESS") + or "WALLET_NOT_CONFIGURED" + ) + solana_wallet = ( + self.node.try_get_context("seller_solana_wallet") + or os.environ.get("SELLER_SOLANA_WALLET_ADDRESS") + or "WALLET_NOT_CONFIGURED" + ) + facilitator_url = ( + os.environ.get("X402_FACILITATOR_URL") or "https://x402.org/facilitator" + ) + price = os.environ.get("X402_PRICE") or "$0.01" + + # ── Lambda function ────────────────────────────────────────── + seller_fn = _lambda.Function( + self, + "SellerFunction", + runtime=_lambda.Runtime.NODEJS_20_X, + architecture=_lambda.Architecture.ARM_64, + handler="index.handler", + # The deploy script runs `npm install` in the lambda/ folder + # before `cdk deploy` so the asset ships node_modules inline. + # Matches the pattern used by agentcore-payments sellers. + code=_lambda.Code.from_asset(LAMBDA_CODE_DIR), + timeout=Duration.seconds(30), + memory_size=256, + environment={ + "SELLER_WALLET_ADDRESS": evm_wallet, + "SELLER_SOLANA_WALLET_ADDRESS": solana_wallet, + "X402_FACILITATOR_URL": facilitator_url, + "X402_PRICE": price, + }, + log_retention=logs.RetentionDays.ONE_WEEK, + description="Fun Facts x402 seller — AgentCore Payments use case", + ) + + # ── HTTP API ───────────────────────────────────────────────── + # CORS is wide-open for the demo so the seller is reachable from + # any caller (the AgentCore Runtime container, a browser-based + # debugger, a curl session). For production, restrict origins to + # the specific agent runtime endpoints that need to call this + # seller, and limit methods to GET + OPTIONS. + http_api = apigwv2.HttpApi( + self, + "SellerHttpApi", + api_name="pay-for-api-fun-facts", + description="Fun Facts x402 seller — pay-per-fact via x402", + cors_preflight=apigwv2.CorsPreflightOptions( + # Demo configuration — restrict to specific origins in + # production (for example, your agent runtime domains). + allow_origins=["*"], + allow_methods=[apigwv2.CorsHttpMethod.ANY], + allow_headers=["*"], + ), + ) + + integration = apigwv2_integrations.HttpLambdaIntegration( + "SellerLambdaIntegration", + handler=seller_fn, + ) + + # Single proxy route catches GET /, GET /facts, GET /health. + http_api.add_routes( + path="/{proxy+}", + methods=[apigwv2.HttpMethod.ANY], + integration=integration, + ) + http_api.add_routes( + path="/", + methods=[apigwv2.HttpMethod.ANY], + integration=integration, + ) + + # ── Outputs ────────────────────────────────────────────────── + CfnOutput( + self, + "SellerApiUrl", + value=http_api.api_endpoint, + description="Invoke URL for the Fun Facts x402 seller API", + ) + CfnOutput( + self, + "SellerEvmWallet", + value=evm_wallet or "(unset)", + description=( + "EVM (Base Sepolia) wallet that receives USDC for paid " + "requests. Set via `cdk deploy -c seller_wallet=0x…` or " + "the SELLER_WALLET_ADDRESS env var." + ), + ) + CfnOutput( + self, + "SellerSolanaWallet", + value=solana_wallet or "(unset)", + description=( + "Solana (Devnet) wallet that receives USDC for paid " + "requests. Set via `cdk deploy -c seller_solana_wallet=…` " + "or the SELLER_SOLANA_WALLET_ADDRESS env var." + ), + ) diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/.gitignore b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/index.js b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/index.js new file mode 100644 index 000000000..b6fc680f6 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/index.js @@ -0,0 +1,273 @@ +/** + * Fun Facts x402 seller — Node.js AWS Lambda function. + * + * Mirrors the house-standard pattern used by agentcore-payments sellers + * (see backend/lambdas/sellers/crypto-price): + * + * - `@x402/hono` `paymentMiddlewareFromHTTPServer` does the full 402 + + * facilitator verify/settle handshake for us — no manual base64 header + * assembly, no manual /verify /settle HTTP calls. + * - Chain Agnostic Improvement Proposal 2 (CAIP-2) network identifiers + * (`eip155:84532` for Base Sepolia, `solana:…` for Devnet) — this is + * what the AgentCore Payments plugin emits on the wire when it signs + * an x402 payload, not the short `base-sepolia` / `solana-devnet` + * strings. + * - Price expressed as human-readable USD (`"$0.01"`) — the x402 + * middleware converts to on-chain atomic amounts. + * - Response shape: `{ x402_content, x402_meta }` — the bazaar-friendly + * schema the AgentCore Registry can index. + * - `declareDiscoveryExtension` so this seller is discoverable through + * the Bazaar Model Context Protocol (MCP). + * + * Multi-network: when both `SELLER_WALLET_ADDRESS` (EVM) and + * `SELLER_SOLANA_WALLET_ADDRESS` are set, both `accepts` entries are + * emitted and the agent picks whichever network its instrument is on. + */ +import { Hono } from "hono"; +import { handle } from "hono/aws-lambda"; +import { + paymentMiddlewareFromHTTPServer, + x402HTTPResourceServer, + x402ResourceServer, +} from "@x402/hono"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { registerExactEvmScheme } from "@x402/evm/exact/server"; +// SVM = Solana Virtual Machine — the on-chain runtime Solana programs +// execute under. The x402 SVM scheme builds + verifies SPL-token +// transfer transactions on Solana. +import { registerExactSvmScheme } from "@x402/svm/exact/server"; +import { + bazaarResourceServerExtension, + declareDiscoveryExtension, +} from "@x402/extensions/bazaar"; + +// ── Config (from Lambda env vars) ─────────────────────────────────────── +// Wallet addresses default to "WALLET_NOT_CONFIGURED" so an unconfigured +// seller emits clearly invalid placeholders in the 402 response. The +// facilitator rejects them at settlement and the agent surfaces a +// helpful error pointing the operator at SELLER_WALLET_ADDRESS / +// SELLER_SOLANA_WALLET_ADDRESS in `.env`. +const X402_CONFIG = { + facilitatorUrl: + process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator", + // CAIP-2 network identifiers + evmNetwork: "eip155:84532", // Base Sepolia + evmPayTo: process.env.SELLER_WALLET_ADDRESS || "WALLET_NOT_CONFIGURED", + solanaNetwork: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Devnet + solanaPayTo: + process.env.SELLER_SOLANA_WALLET_ADDRESS || "WALLET_NOT_CONFIGURED", +}; + +const PRICE = process.env.X402_PRICE || "$0.01"; + +// ── Fun Facts data ────────────────────────────────────────────────────── +const FACTS = { + space: [ + "A day on Venus is longer than its year — it takes 243 Earth days to rotate but only 225 days to orbit the sun.", + "Neutron stars are so dense that a sugar-cube-sized sample would weigh about 1 billion tons on Earth.", + "The largest known volcano in the solar system, Olympus Mons on Mars, is nearly three times taller than Mount Everest.", + "There is a planet made largely of diamond — 55 Cancri e, about 40 light-years away.", + "Saturn's density is so low that, hypothetically, it would float in a bathtub of water large enough to hold it.", + ], + oceans: [ + "More than 80 percent of the ocean has never been mapped, explored, or even seen by humans.", + "The Mariana Trench reaches nearly 11,000 meters deep — taller than Mount Everest turned upside down.", + "Hydrothermal vents on the ocean floor support ecosystems that never see sunlight.", + "Blue whales' hearts are so large that a human could swim through their arteries.", + "Plankton in the ocean produce more than half of the oxygen we breathe.", + ], + ai: [ + "The term 'artificial intelligence' was coined at the Dartmouth Workshop in 1956.", + "Transformer architectures, introduced in 2017, underpin nearly every modern large language model.", + "Reinforcement learning from human feedback (RLHF) is what made instruction-following LLMs practical.", + "Chess AI definitively surpassed human world champions in 1997 with IBM's Deep Blue.", + "Modern LLMs are trained on tokens measured in the trillions.", + ], + payments: [ + "The x402 protocol revives an HTTP status code — 402 Payment Required — that was reserved in RFC 7231 but never standardized.", + "Stablecoins like USDC settle on-chain in seconds, versus days for traditional wire transfers.", + "Micropayments were first proposed by Ted Nelson in the 1960s as part of his Project Xanadu vision.", + "Account abstraction on Ethereum makes gasless agent payments possible via meta-transactions.", + "The first cryptocurrency micropayment channel was demonstrated in 2013 by Meni Rosenfeld and Peter Todd.", + ], + default: [ + "Honey found in Egyptian tombs is still edible — honey does not spoil.", + "Octopuses have three hearts and blue blood.", + "Bananas are berries, but strawberries are not.", + "The Eiffel Tower can grow more than 15 cm taller in summer due to thermal expansion.", + "Wombat droppings are cube-shaped.", + ], +}; + +const SUPPORTED_TOPICS = Object.keys(FACTS).filter((k) => k !== "default"); + +function pickFact(rawTopic) { + const key = String(rawTopic || "").trim().toLowerCase(); + const resolved = FACTS[key] ? key : "default"; + const pool = FACTS[resolved]; + return { topic: resolved, fact: pool[Math.floor(Math.random() * pool.length)] }; +} + +function buildAccepts(price) { + const NOT_CONFIGURED = "WALLET_NOT_CONFIGURED"; + const accepts = []; + // Treat the placeholder the same as an unset env var: still emit the + // accepts entry so the 402 response has the right shape, but the + // facilitator will reject any payment proof at settlement and the + // agent surfaces a clear error message. + if (X402_CONFIG.evmPayTo && X402_CONFIG.evmPayTo !== NOT_CONFIGURED) { + accepts.push({ + scheme: "exact", + price, + network: X402_CONFIG.evmNetwork, + payTo: X402_CONFIG.evmPayTo, + }); + } + if (X402_CONFIG.solanaPayTo && X402_CONFIG.solanaPayTo !== NOT_CONFIGURED) { + accepts.push({ + scheme: "exact", + price, + network: X402_CONFIG.solanaNetwork, + payTo: X402_CONFIG.solanaPayTo, + }); + } + if (!accepts.length) { + // No wallet configured — emit an EVM entry anyway so the 402 response + // has the right shape; the facilitator will reject the proof at + // settlement. Keeps the error message useful during first-run setup. + accepts.push({ + scheme: "exact", + price, + network: X402_CONFIG.evmNetwork, + payTo: NOT_CONFIGURED, + }); + } + return accepts; +} + +// ── Hono app + x402 middleware ────────────────────────────────────────── +const app = new Hono(); + +// Request logging — same shape as the reference seller so CloudWatch +// queries are portable. +app.use("*", async (c, next) => { + const start = Date.now(); + const sig = c.req.header("payment-signature"); + console.log( + JSON.stringify({ + event: "request_in", + method: c.req.method, + path: c.req.path, + hasPaymentSignature: !!sig, + paymentSignatureLength: sig?.length || 0, + }) + ); + await next(); + console.log( + JSON.stringify({ + event: "response_out", + method: c.req.method, + path: c.req.path, + status: c.res.status, + durationMs: Date.now() - start, + hasPaymentSignature: !!sig, + }) + ); +}); + +// x402 server — EVM + SVM schemes, Bazaar discovery extension. +const facilitatorClient = new HTTPFacilitatorClient({ + url: X402_CONFIG.facilitatorUrl, +}); +const server = new x402ResourceServer(facilitatorClient); +registerExactEvmScheme(server); +registerExactSvmScheme(server); +server.registerExtension(bazaarResourceServerExtension); + +// Declare one paid route: GET /facts. The Bazaar discovery extension +// exposes the topic query-parameter schema + an example output so the +// AgentCore Registry can list this seller. +const routes = { + "GET /facts": { + accepts: buildAccepts(PRICE), + extensions: { + ...declareDiscoveryExtension({ + input: { topic: "space" }, + inputSchema: { + properties: { + topic: { + type: "string", + description: `One of ${SUPPORTED_TOPICS.join(", ")} (or any other string for a random general fact).`, + }, + }, + required: [], + }, + bodyType: "query", + output: { + example: { + x402_content: { + type: "text", + data: '{"topic":"space","fact":"A day on Venus is longer than its year …"}', + title: "Fun fact: space", + mime_type: "application/json", + }, + x402_meta: { + seller: "pay-for-api-fun-facts", + version: "1.0", + }, + }, + }, + }), + }, + }, +}; + +const httpServer = new x402HTTPResourceServer(server, routes); +await httpServer.initialize(); +app.use( + paymentMiddlewareFromHTTPServer(httpServer, undefined, undefined, false) +); + +// ── Routes ────────────────────────────────────────────────────────────── + +// Paid route +app.get("/facts", (c) => { + const topic = c.req.query("topic") || "default"; + const { topic: resolvedTopic, fact } = pickFact(topic); + return c.json({ + x402_content: { + type: "text", + data: JSON.stringify({ topic: resolvedTopic, fact }), + title: `Fun fact: ${resolvedTopic}`, + mime_type: "application/json", + }, + x402_meta: { + seller: "pay-for-api-fun-facts", + version: "1.0", + generated_at: new Date().toISOString(), + supported_topics: SUPPORTED_TOPICS, + }, + }); +}); + +// Public health check — no payment required. +app.get("/health", (c) => + c.json({ + status: "ok", + service: "pay-for-api-fun-facts", + price: PRICE, + networks: buildAccepts(PRICE).map((a) => a.network), + supported_topics: SUPPORTED_TOPICS, + }) +); + +// Discovery root. +app.get("/", (c) => + c.json({ + service: "pay-for-api-fun-facts", + paidEndpoints: ["GET /facts?topic="], + price: PRICE, + }) +); + +export const handler = handle(app); diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/package.json b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/package.json new file mode 100644 index 000000000..135b18885 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/seller/lambda/package.json @@ -0,0 +1,14 @@ +{ + "name": "x402-seller-pay-for-api", + "version": "1.0.0", + "type": "module", + "description": "x402 Fun Facts seller — pay-for-API sample", + "dependencies": { + "@x402/hono": "^2.7.0", + "@x402/core": "^2.7.0", + "@x402/evm": "^2.7.0", + "@x402/svm": "^2.7.0", + "@x402/extensions": "^2.7.0", + "hono": "^4.0.0" + } +} diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/README.md b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/README.md new file mode 100644 index 000000000..fb42d30d2 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/README.md @@ -0,0 +1,32 @@ +# test/integration/ + +Operational scripts for the **Pay-For-API** use case. Run them from the +use-case root (`02-use-cases/01-pay-for-api/`); each script resolves its +paths relative to this folder, so it does not matter which directory you +invoke them from as long as the repo layout is intact. + +Mirrors the pattern used by +[`agentcore-payments/test/integration/`](../../../../agentcore-payments/test/integration). + +| Script | What it does | +|--------|--------------| +| `setup-roles.sh` | Creates the four IAM roles the notebook assumes into (`ControlPlane`, `Management`, `ProcessPayment`, `ResourceRetrieval`) with the separation-of-duties policy model described in the main [README](../../README.md). Idempotent — safe to re-run. Writes the role ARNs back into `.env`. | +| `setup-env.sh` | Interactive env setup. Copies `env-sample.txt` → `.env` on first run, then walks through the empty values (role ARNs, Coinbase CDP credentials, seller payout wallet) and prompts only for the ones that are still blank. Re-run with `--force-reprompt` to replace already-set values. | +| `deploy-seller.sh` | `npm install` the seller Lambda's `node_modules`, then `cdk bootstrap` (first run only) and `cdk deploy` the seller stack. Writes `seller/cdk/outputs.json` and prints `SellerApiUrl`. | +| `destroy-seller.sh` | `cdk destroy --force` the seller stack. | + +## Typical order + +```bash +# From 02-use-cases/01-pay-for-api/ +bash test/integration/setup-roles.sh # create IAM roles (once per account) +bash test/integration/setup-env.sh # prompt for Coinbase creds + other secrets +bash test/integration/deploy-seller.sh # deploy the paid API +# paste SellerApiUrl into .env as SELLER_API_URL +jupyter notebook pay-for-api.ipynb +# …work through the notebook… +bash test/integration/destroy-seller.sh # when done +``` + +The notebook's §3 also invokes `deploy-seller.sh` for you, so running the +script manually is optional — whichever is more comfortable. diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-agent.sh b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-agent.sh new file mode 100755 index 000000000..7b819fcad --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-agent.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Deploy the Pay for API buyer agent to AgentCore Runtime via AWS CDK. +# +# The agent container image is built in AWS CodeBuild (not on this +# machine) so no Docker install is required. `cdk deploy` uploads +# agent/container/ as an S3 asset, CodeBuild pulls it, builds + pushes +# to ECR, and the Runtime resource pulls from there on invoke. +# +# Prerequisites: +# - AWS CLI v2 configured (aws configure) +# - AWS CDK v2 installed (npm install -g aws-cdk) +# - Python 3.10+ with pip (for the CDK Python dependencies) +# +# Usage (from anywhere): +# bash test/integration/deploy-agent.sh +# +# Writes outputs to agent/cdk/outputs.json. The notebook's §8 reads that +# file to pick up the Runtime ARN. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +USE_CASE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CDK_DIR="${USE_CASE_ROOT}/agent/cdk" +CONTAINER_DIR="${USE_CASE_ROOT}/agent/container" + +# Pull region from .env so it matches whatever the notebook provisioned. +if [ -f "${USE_CASE_ROOT}/.env" ]; then + # Guard against unreplaced placeholders from env-sample.txt. + if grep -q "" "${USE_CASE_ROOT}/.env"; then + echo "❌ ${USE_CASE_ROOT}/.env still contains placeholders." >&2 + echo " Run: bash test/integration/setup-roles.sh" >&2 + echo " before deploying the agent." >&2 + exit 1 + fi + set -a + # shellcheck disable=SC1091 + source "${USE_CASE_ROOT}/.env" + set +a +fi + +REGION="${AWS_REGION:-us-west-2}" + +echo "── Pay for API — Agent Deploy ─────────────────────────────" +echo "Region: ${REGION}" +echo "CDK: ${CDK_DIR}" +echo "Container: ${CONTAINER_DIR}" +echo "" +echo "The container image is built in AWS CodeBuild (no Docker needed on" +echo "this machine). First run can take 4–6 minutes for the build; subsequent" +echo "deploys only rebuild if agent/container/ changed." +echo "" + +# ── 1. CDK Python venv ── +if [ ! -d "${CDK_DIR}/.venv" ]; then + echo "Creating Python venv for CDK..." + python3 -m venv "${CDK_DIR}/.venv" +fi +# shellcheck disable=SC1091 +source "${CDK_DIR}/.venv/bin/activate" + +echo "Installing CDK Python dependencies..." +pip install --quiet --upgrade pip +pip install --quiet -r "${CDK_DIR}/requirements.txt" + +# ── 2. Bootstrap (idempotent) ── +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +if ! aws cloudformation describe-stacks --stack-name CDKToolkit --region "${REGION}" >/dev/null 2>&1; then + echo "" + echo "Bootstrapping CDK for ${ACCOUNT_ID}/${REGION}..." + (cd "${CDK_DIR}" && cdk bootstrap "aws://${ACCOUNT_ID}/${REGION}") +else + echo "CDK already bootstrapped for ${ACCOUNT_ID}/${REGION}." +fi + +# ── 3. Deploy ── +echo "" +echo "Deploying AgentCorePaymentsBuyerAgentStack..." +echo "(CDK synth + asset upload + CodeBuild run — typically 5–8 min on the" +echo " first deploy, ~2 min on subsequent runs if nothing changed.)" +(cd "${CDK_DIR}" && cdk deploy --require-approval never --outputs-file ./outputs.json) + +RUNTIME_ARN="$(python3 -c 'import json; print(json.load(open("'"${CDK_DIR}"'/outputs.json"))["AgentCorePaymentsBuyerAgentStack"]["AgentRuntimeArn"])')" +RUNTIME_ID="$(python3 -c 'import json; print(json.load(open("'"${CDK_DIR}"'/outputs.json"))["AgentCorePaymentsBuyerAgentStack"]["AgentRuntimeId"])')" + +echo "" +echo "── Deploy Complete ─────────────────────────────────────────" +echo "✅ AgentRuntimeArn: ${RUNTIME_ARN}" +echo " AgentRuntimeId: ${RUNTIME_ID}" +echo "" +echo "The notebook §8 reads agent/cdk/outputs.json to pick up these values." diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-seller.sh b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-seller.sh new file mode 100755 index 000000000..43d17a581 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/deploy-seller.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Deploy the Fun Facts x402 seller stack via AWS CDK. +# +# The Lambda is Node.js with pre-installed node_modules (same pattern as +# agentcore-payments sellers) so this script runs `npm install` inside +# seller/lambda/ before `cdk deploy` packages the asset. +# +# Prerequisites: +# - AWS CLI v2 configured (aws configure) +# - AWS CDK v2 installed (npm install -g aws-cdk) +# - Node.js 20+ and npm +# - Python 3.10+ with pip (for the CDK Python dependencies) +# +# Optional: +# - SELLER_WALLET_ADDRESS=0x… # EVM (Base Sepolia) payout wallet +# - SELLER_SOLANA_WALLET_ADDRESS=… # Solana (Devnet) payout wallet +# - X402_FACILITATOR_URL=… # Override facilitator (defaults to x402.org) +# +# Usage (from anywhere): +# bash test/integration/deploy-seller.sh +# +# After deploy, copy the printed SellerApiUrl into .env as SELLER_API_URL. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Scripts live at /test/integration/ — ../../ resolves the +# use-case root, the anchor for seller/ and .env. +USE_CASE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +LAMBDA_DIR="${USE_CASE_ROOT}/seller/lambda" +CDK_DIR="${USE_CASE_ROOT}/seller/cdk" + +# Pull the payout wallets + region from .env so the values the notebook +# prompted for in §2 flow through to the CDK deploy. Shell-env vars +# already set on the current session take precedence. +if [ -f "${USE_CASE_ROOT}/.env" ]; then + # Guard against unreplaced placeholders like "" — bash + # would try to interpret `` as a redirection and error + # out with "No such file or directory" when sourcing. Tell the user + # cleanly what went wrong instead. + if grep -q "" "${USE_CASE_ROOT}/.env"; then + echo "❌ ${USE_CASE_ROOT}/.env still contains placeholders." >&2 + echo " Run: bash test/integration/setup-roles.sh" >&2 + echo " (or re-run §2 in the notebook) before deploying." >&2 + exit 1 + fi + set -a + # shellcheck disable=SC1091 + source "${USE_CASE_ROOT}/.env" + set +a +fi + +REGION="${AWS_REGION:-us-west-2}" + +echo "── Pay for API — Seller Deploy ────────────────────────────" +echo "Region: ${REGION}" +echo "Lambda: ${LAMBDA_DIR}" +echo "CDK: ${CDK_DIR}" +echo "" + +# ── 0. Wallet sanity check ── +warn=() +if [ -z "${SELLER_WALLET_ADDRESS:-}" ]; then + warn+=(" • SELLER_WALLET_ADDRESS (EVM) — required for Base Sepolia payments") +fi +if [ -z "${SELLER_SOLANA_WALLET_ADDRESS:-}" ]; then + warn+=(" • SELLER_SOLANA_WALLET_ADDRESS (Solana) — required for Solana Devnet payments") +fi +if [ ${#warn[@]} -gt 0 ]; then + echo "⚠️ One or more payout wallets are not set:" + for line in "${warn[@]}"; do + echo "${line}" + done + echo "" + echo " Without a payout wallet for a given network the seller emits an" + echo " invalid 402 for that network and the agent cannot pay on it." + echo " At minimum you need SELLER_WALLET_ADDRESS for the §8 EVM run." + echo "" + echo " Set the missing ones in .env and re-run this script, e.g.:" + echo " export SELLER_WALLET_ADDRESS=0xYourBaseSepoliaAddress" + echo " export SELLER_SOLANA_WALLET_ADDRESS=YourSolanaDevnetAddress" + echo "" + read -r -p " Continue anyway? [y/N] " ok + case "${ok}" in + y|Y|yes|YES) ;; + *) echo " Aborted."; exit 1 ;; + esac + echo "" +fi + +# ── 1. Install Lambda node_modules ── +echo "Installing Lambda node_modules..." +(cd "${LAMBDA_DIR}" && npm install --silent --omit=dev) + +# ── 2. CDK Python venv ── +if [ ! -d "${CDK_DIR}/.venv" ]; then + echo "Creating Python venv for CDK..." + python3 -m venv "${CDK_DIR}/.venv" +fi +# shellcheck disable=SC1091 +source "${CDK_DIR}/.venv/bin/activate" + +echo "Installing CDK Python dependencies..." +pip install --quiet --upgrade pip +pip install --quiet -r "${CDK_DIR}/requirements.txt" + +# ── 3. Bootstrap (idempotent) ── +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +if ! aws cloudformation describe-stacks --stack-name CDKToolkit --region "${REGION}" >/dev/null 2>&1; then + echo "" + echo "Bootstrapping CDK for ${ACCOUNT_ID}/${REGION}..." + (cd "${CDK_DIR}" && cdk bootstrap "aws://${ACCOUNT_ID}/${REGION}") +fi + +# ── 4. Deploy ── +echo "" +echo "Deploying AgentCorePaymentsFunFactsSellerStack..." +(cd "${CDK_DIR}" && cdk deploy --require-approval never --outputs-file ./outputs.json) + +API_URL="$(python3 -c 'import json; print(json.load(open("'"${CDK_DIR}"'/outputs.json"))["AgentCorePaymentsFunFactsSellerStack"]["SellerApiUrl"])')" +EVM_WALLET="$(python3 -c 'import json; print(json.load(open("'"${CDK_DIR}"'/outputs.json"))["AgentCorePaymentsFunFactsSellerStack"]["SellerEvmWallet"])')" +SVM_WALLET="$(python3 -c 'import json; print(json.load(open("'"${CDK_DIR}"'/outputs.json"))["AgentCorePaymentsFunFactsSellerStack"]["SellerSolanaWallet"])')" + +echo "" +echo "── Deploy Complete ─────────────────────────────────────────" +echo "✅ SellerApiUrl: ${API_URL}" +echo " EVM payout wallet: ${EVM_WALLET}" +echo " Solana payout wallet: ${SVM_WALLET}" +echo "" + +# Upsert SELLER_API_URL into .env so §3/§5/§7 in the notebook pick it +# up automatically on the next load_dotenv() without the user editing +# by hand. Preserves comments and other lines. +ENV_FILE="${USE_CASE_ROOT}/.env" +if [ ! -f "${ENV_FILE}" ]; then + cp "${USE_CASE_ROOT}/env-sample.txt" "${ENV_FILE}" +fi +python3 - </test/integration/ — ../../ resolves the +# use-case root. +USE_CASE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CDK_DIR="${USE_CASE_ROOT}/seller/cdk" + +if [ -d "${CDK_DIR}/.venv" ]; then + # shellcheck disable=SC1091 + source "${CDK_DIR}/.venv/bin/activate" +fi + +echo "Destroying AgentCorePaymentsFunFactsSellerStack..." +(cd "${CDK_DIR}" && cdk destroy --force) + +echo "" +echo "✅ Seller stack destroyed." diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-env.sh b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-env.sh new file mode 100755 index 000000000..3ab9f96a9 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-env.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Seed .env from env-sample.txt and generate a fresh USER_ID. Idempotent: +# re-runs leave existing values alone. +# +# Usage: +# bash test/integration/setup-env.sh + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "${SCRIPT_DIR}/setup_env.py" diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-roles.sh b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-roles.sh new file mode 100755 index 000000000..3ca0291dd --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup-roles.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +# setup-roles.sh — create the four IAM roles the notebook assumes into. +# +# Creates, idempotently: +# AgentCorePaymentsControlPlaneRole — manages Manager/Connector/CredentialProvider +# AgentCorePaymentsManagementRole — manages Instrument/Session (explicit Deny on ProcessPayment) +# AgentCorePaymentsProcessPaymentRole — signs payments, reads Instrument/Session +# AgentCorePaymentsResourceRetrievalRole — service-assumed, retrieves credentials at runtime +# +# Policies are based on the four-role separation-of-duties model +# recommended for AgentCore Payments (ControlPlane / Management / +# ProcessPayment / ResourceRetrieval — see the main README for the +# full policy text). +# After creating the roles, writes their ARNs into the use-case .env so the +# notebook picks them up without further editing. +# +# Re-running is safe: existing roles are left alone, their policies are +# updated in place, and .env values are only written if empty. +# +# Usage: +# bash test/integration/setup-roles.sh + +set -euo pipefail + +# ── Path plumbing ───────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +USE_CASE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +ENV_FILE="${USE_CASE_ROOT}/.env" +TEMPLATE="${USE_CASE_ROOT}/env-sample.txt" + +# ── Prerequisites ───────────────────────────────────────────────────── +command -v aws >/dev/null 2>&1 || { + echo "❌ aws CLI not found — install AWS CLI v2 first." >&2 + exit 1 +} + +ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" +if [ -z "${ACCOUNT_ID}" ] || [ "${ACCOUNT_ID}" = "None" ]; then + echo "❌ Could not resolve AWS account. Run 'aws configure' first." >&2 + exit 1 +fi + +echo "✅ Account: ${ACCOUNT_ID}" +echo + +# ── Role definitions ────────────────────────────────────────────────── +CP_ROLE="AgentCorePaymentsControlPlaneRole" +MGMT_ROLE="AgentCorePaymentsManagementRole" +PP_ROLE="AgentCorePaymentsProcessPaymentRole" +RR_ROLE="AgentCorePaymentsResourceRetrievalRole" + +# Standard account trust policy — lets any IAM principal in this account +# assume the role. Good enough for a tutorial; tighten for production. +ACCOUNT_TRUST_POLICY=$(cat </dev/null 2>&1 +} + +create_or_update_role() { + local name="$1" + local trust="$2" + local policy_name="$3" + local policy_doc="$4" + + if role_exists "${name}"; then + echo " ↺ ${name} already exists — updating trust + policy" + aws iam update-assume-role-policy \ + --role-name "${name}" \ + --policy-document "${trust}" >/dev/null + else + echo " + Creating ${name}" + aws iam create-role \ + --role-name "${name}" \ + --assume-role-policy-document "${trust}" \ + --description "AgentCore Payments tutorial role" >/dev/null + fi + + aws iam put-role-policy \ + --role-name "${name}" \ + --policy-name "${policy_name}" \ + --policy-document "${policy_doc}" >/dev/null + echo " ↳ policy ${policy_name} applied" +} + +# ── Create / update roles ───────────────────────────────────────────── +echo "=== Creating / updating IAM roles ===" +create_or_update_role "${CP_ROLE}" "${ACCOUNT_TRUST_POLICY}" "ControlPlanePolicy" "${CP_POLICY}" +create_or_update_role "${MGMT_ROLE}" "${ACCOUNT_TRUST_POLICY}" "ManagementPolicy" "${MGMT_POLICY}" +create_or_update_role "${PP_ROLE}" "${ACCOUNT_TRUST_POLICY}" "ProcessPaymentPolicy" "${PP_POLICY}" +create_or_update_role "${RR_ROLE}" "${SERVICE_TRUST_POLICY}" "ResourceRetrievalPolicy" "${RR_POLICY}" +echo + +CP_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${CP_ROLE}" +MGMT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${MGMT_ROLE}" +PP_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${PP_ROLE}" + +echo "=== Role ARNs ===" +echo " CONTROL_PLANE_ROLE_ARN: ${CP_ROLE_ARN}" +echo " MANAGEMENT_ROLE_ARN: ${MGMT_ROLE_ARN}" +echo " PROCESS_PAYMENT_ROLE_ARN: ${PP_ROLE_ARN}" +echo " RESOURCE_RETRIEVAL_ROLE_ARN: ${RR_ROLE_ARN}" +echo + +# ── Write ARNs back to .env ─────────────────────────────────────────── +# Only set values for keys that are empty or have the placeholder. +# Never clobber a hand-edited value. +if [ ! -f "${ENV_FILE}" ]; then + if [ -f "${TEMPLATE}" ]; then + cp "${TEMPLATE}" "${ENV_FILE}" + echo " Seeded ${ENV_FILE} from env-sample.txt" + else + touch "${ENV_FILE}" + echo " Created empty ${ENV_FILE}" + fi +fi + +write_env_var() { + local key="$1" + local value="$2" + # Match KEY=, KEY=<…>, or KEY=arn:aws:iam:::… + local current + current="$(awk -F '=' -v k="${key}" '$1 == k { sub(/^[^=]+=/, ""); print; exit }' "${ENV_FILE}" 2>/dev/null || true)" + + case "${current}" in + "" | "<"* | *""*) + if grep -q "^${key}=" "${ENV_FILE}"; then + # in-place update using a tmp file so we don't depend on sed -i flavour + awk -F '=' -v k="${key}" -v v="${value}" \ + '{ if ($1 == k) print k "=" v; else print $0 }' "${ENV_FILE}" > "${ENV_FILE}.tmp" + mv "${ENV_FILE}.tmp" "${ENV_FILE}" + else + echo "${key}=${value}" >> "${ENV_FILE}" + fi + echo " ✅ Wrote ${key} to .env" + ;; + *) + echo " ↷ ${key} already set — leaving alone (${current})" + ;; + esac +} + +echo "=== Updating ${ENV_FILE} ===" +write_env_var "CONTROL_PLANE_ROLE_ARN" "${CP_ROLE_ARN}" +write_env_var "MANAGEMENT_ROLE_ARN" "${MGMT_ROLE_ARN}" +write_env_var "PROCESS_PAYMENT_ROLE_ARN" "${PP_ROLE_ARN}" +write_env_var "RESOURCE_RETRIEVAL_ROLE_ARN" "${RR_ROLE_ARN}" + +echo +echo "✅ Done. Next: run the §2 setup cell in the notebook to fill in credentials" diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup_env.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup_env.py new file mode 100644 index 000000000..472cc9ca2 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/test/integration/setup_env.py @@ -0,0 +1,139 @@ +"""Env-file plumbing for the pay-for-api tutorial. + +Provides a small set of helpers the notebook + utility scripts use to +seed ``.env`` from ``env-sample.txt`` and write non-secret values +(``USER_ID``, role ARNs, manager IDs, etc.) into it. + +User-supplied wallet-provider secrets (Coinbase / Privy keys, Privy +authorization private key) are pasted into ``.env`` by hand. The +notebook's §2 cell opens ``.env`` in the editor for the user and lists +the keys that still need values. The notebook's §4 then reads those +secrets once, passes them to ``CreatePaymentCredentialProvider``, and +AgentCore Identity stores them in AWS Secrets Manager under KMS +encryption and surfaces only the secret ARN to the agent. The local +``.env`` copy is no longer needed at runtime after that point and can +be cleared by hand. Nothing in this module ever logs, transmits, or +reads back secret material. + +Entry points: + +- ``python3 test/integration/setup_env.py`` — CLI; seeds ``.env`` and + generates a fresh ``USER_ID`` if missing. +- ``from setup_env import seed_env, write_env_var`` — programmatic API. +""" + +from __future__ import annotations + +import pathlib +import shutil +import sys +import uuid + +# ── Path plumbing ───────────────────────────────────────────────────── +# The Python module lives at test/integration/setup_env.py; walk up two +# levels to land at the use-case root where env-sample.txt and .env live. +HERE = pathlib.Path(__file__).resolve().parent +USE_CASE_ROOT = HERE.parent.parent +TEMPLATE = USE_CASE_ROOT / "env-sample.txt" +ENV_FILE = USE_CASE_ROOT / ".env" + +# Tokens that mean "this slot has not been filled yet" — treat like empty. +PLACEHOLDER_PREFIXES = ("<",) +PLACEHOLDER_SUBSTRINGS = ("",) + + +def _is_empty(value: str) -> bool: + """True if the value is unset, blank, or a template placeholder.""" + if not value: + return True + if any(value.startswith(p) for p in PLACEHOLDER_PREFIXES): + return True + if any(s in value for s in PLACEHOLDER_SUBSTRINGS): + return True + return False + + +def _read_env_lines() -> list[str]: + return ENV_FILE.read_text().splitlines() if ENV_FILE.exists() else [] + + +def _current_value(key: str) -> str: + for line in _read_env_lines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1] + return "" + + +def write_env_var(key: str, value: str) -> None: + """Update or append KEY=VALUE in .env without touching other lines. + + Only intended for non-secret values written programmatically by the + notebook (USER_ID, role ARNs, manager IDs, instrument IDs, session + IDs, wallet addresses). Wallet-provider secrets (Coinbase / Privy + keys, Privy authorization private key) are pasted into ``.env`` by + the user manually and never flow through this function. Once §4 of + the notebook calls ``CreatePaymentCredentialProvider``, those + secrets are stored in AWS Secrets Manager under AgentCore Identity + and only the credential-provider ARN remains in ``.env``. + """ + lines = _read_env_lines() + replaced = False + out: list[str] = [] + for line in lines: + if line.startswith(f"{key}="): + out.append(f"{key}={value}") + replaced = True + else: + out.append(line) + if not replaced: + out.append(f"{key}={value}") + ENV_FILE.write_text("\n".join(out) + "\n") + + +def seed_env() -> bool: + """Create .env from env-sample.txt if it doesn't exist and ensure + USER_ID is set to a unique UUID. + + Returns True if .env was created on this call, False if it was + already there. + """ + seeded = False + if not ENV_FILE.exists(): + if not TEMPLATE.exists(): + raise FileNotFoundError( + f"env-sample.txt not found at {TEMPLATE}. " + "Run this from the use-case root with the template in place." + ) + shutil.copy2(TEMPLATE, ENV_FILE) + seeded = True + + # Auto-generate USER_ID on first run. The notebook uses USER_ID as + # the operator identifier on CreatePaymentSession headers. A fixed + # value across runs caused collisions in the service's vendor-user + # mapping, so each fresh .env gets its own UUID. + # + # The `pay-for-api-` prefix marks this as a tutorial-scoped + # identifier; production code should generate USER_IDs from your + # own auth system rather than reusing this format. + if _is_empty(_current_value("USER_ID")): + write_env_var("USER_ID", f"pay-for-api-{uuid.uuid4()}") + + return seeded + + +def _cli() -> int: + if seed_env(): + print(f"✅ Seeded {ENV_FILE} from env-sample.txt.") + else: + print(f"↷ Found existing {ENV_FILE} — left in place.") + print() + print( + "Open .env in your editor and fill in any missing values " + "(secrets are paste-only; non-secrets are written for you by " + "later notebook cells)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(_cli()) diff --git a/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/utils.py b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/utils.py new file mode 100644 index 000000000..2bac1f4c3 --- /dev/null +++ b/01-tutorials/13-AgentCore-payments/02-use-cases/pay-for-api-agent/utils.py @@ -0,0 +1,160 @@ +""" +utils.py — shared helpers for the pay-for-api notebook. + +Small wrappers around boto3 for pretty-printing responses, assuming IAM +roles, polling for status transitions, and tolerating idempotent +create calls. +""" + +import json +import time + +import boto3 +import botocore.exceptions + + +def pp(label: str, response: dict) -> None: + """Pretty-print an API response, stripping ResponseMetadata.""" + data = {k: v for k, v in response.items() if k != "ResponseMetadata"} + print(f"\n{'=' * 60}") + print(f" {label}") + print(f"{'=' * 60}") + print(json.dumps(data, indent=2, default=str)) + + +def assume_role( + session: boto3.Session, + role_arn: str, + session_name: str = "tutorial-session", +) -> boto3.Session: + """Assume an IAM role and return a boto3 Session with auto-refreshing credentials. + + Uses botocore's ``RefreshableCredentials`` under the hood so sessions + stay valid past the default 1-hour STS expiry without the caller + having to rebuild clients. This matters for the notebook, where a + user can leave §5.1's session sitting for hours before coming back + to §7 / §9. + + Immediately verifies the assumed identity by calling + get_caller_identity(); raises if the assumption fails outright. + """ + from botocore.credentials import RefreshableCredentials + from botocore.session import Session as BotocoreSession + + sts = session.client("sts") + + def _refresh() -> dict: + creds = sts.assume_role( + RoleArn=role_arn, + RoleSessionName=session_name, + )["Credentials"] + return { + "access_key": creds["AccessKeyId"], + "secret_key": creds["SecretAccessKey"], + "token": creds["SessionToken"], + "expiry_time": creds["Expiration"].isoformat(), + } + + refreshable_creds = RefreshableCredentials.create_from_metadata( + metadata=_refresh(), + refresh_using=_refresh, + method="sts-assume-role", + ) + + botocore_session = BotocoreSession() + botocore_session._credentials = refreshable_creds + botocore_session.set_config_variable("region", session.region_name) + + new_session = boto3.Session(botocore_session=botocore_session) + + assumed_arn = new_session.client("sts").get_caller_identity()["Arn"] + print(f" Assumed: {assumed_arn}") + return new_session + + +def wait_for_status( + client_fn, + expected_status: str, + poll_interval: int = 5, + timeout: int = 120, + **kwargs, +) -> dict: + """Poll a Get* API until the resource reaches expected_status. + + Resolves status from these response shapes (checked in order): + - Top-level ``status`` field (Manager, Connector responses) + - ``paymentInstrument.status`` (GetPaymentInstrument response) + + Raises TimeoutError if the resource has not reached expected_status + within ``timeout`` seconds. + Raises RuntimeError immediately if the resource enters a terminal + failure state (any status ending in ``_FAILED``). + """ + deadline = time.time() + timeout + while True: + resp = client_fn(**kwargs) + status = resp.get("status") or resp.get("paymentInstrument", {}).get("status") + print(f" Status: {status}") + if isinstance(status, str) and status.endswith("_FAILED"): + raise RuntimeError(f"Resource reached failure state: '{status}'") + if status == expected_status: + return resp + if time.time() >= deadline: + raise TimeoutError( + f"Resource still in '{status}' after {timeout}s — check the console for errors" + ) + time.sleep(poll_interval) + + +def idempotent_create( + create_fn, conflict_msg: str = "Resource already exists", **kwargs +) -> dict | None: + """Call create_fn; handle ConflictException gracefully. + + Returns the API response on success, or None if the resource already exists. + Re-raises any other ClientError. + """ + try: + return create_fn(**kwargs) + except botocore.exceptions.ClientError as exc: + if exc.response["Error"]["Code"] == "ConflictException": + print(f" ⚠️ {conflict_msg} — skipping create") + return None + raise + + +def write_env_updates(updates: dict, env_path: str = ".env") -> None: + """Upsert key=value pairs into a dotenv file, preserving other lines. + + Updates in-place — matching keys are replaced, new keys are appended, + comments and blank lines are preserved. Values are written verbatim + (no quoting), matching the existing .env style in this tutorial. + + Used only for non-secret values written by the notebook at runtime + (USER_ID, role ARNs, manager IDs, instrument IDs, session IDs, + wallet addresses). Wallet-provider secrets (Coinbase / Privy keys, + Privy authorization private key) are pasted into ``.env`` by the + user manually and never flow through this function. After §4 of + the notebook calls ``CreatePaymentCredentialProvider``, AgentCore + Identity stores those secrets in AWS Secrets Manager under KMS + encryption and only the credential-provider ARN remains in + ``.env`` for runtime use. The ``.env`` file itself is gitignored + from use-case creation. + """ + import pathlib + + path = pathlib.Path(env_path) + existing = path.read_text().splitlines() if path.exists() else [] + seen = set() + out = [] + for line in existing: + key = line.split("=", 1)[0].strip() + if key in updates: + out.append(f"{key}={updates[key]}") + seen.add(key) + else: + out.append(line) + for key, value in updates.items(): + if key not in seen: + out.append(f"{key}={value}") + path.write_text("\n".join(out) + "\n")