
Root-cause analysis in plain English with Amazon Quick
A semantic layer and a well-instructed chat agent turn "why did it happen?" from a three-day investigation into a single question. Here's how to build, test, and deploy it with AWS CDK.
If you've ever stared at a spike on a dashboard and thought "great, but why?", you're not alone. In this post, we show how to build a conversational analytics agent with Amazon Quick , the evolution of Amazon QuickSight, on top of a lakehouse with Amazon Simple Storage Service (Amazon S3) , AWS Glue Data Catalog , and Amazon Athena . The agent answers "why did it happen?" questions in seconds instead of days. You'll learn how the semantic layer and the agent's instructions work together, how to test an AI analyst against known answers, and which pitfalls cost us the most time. The complete code is available in the GitHub repo .
Here's the scenario we kept coming back to. It's Monday morning, and a Product Manager (PM) on a Cards team gets an alert: chargeback volume is up 250% compared to last week. The dashboard shows the spike. It doesn't show the cause. So the PM starts filtering by country, card type, channel, and merchant category, exports to a spreadsheet, and eventually files a ticket with the data team for a custom query. The answer arrives on Wednesday. At that point, the fraud has been running for five days.
Key benefits:
- One question instead of three days: "Why did chargebacks increase in the week of January 15th?" traces the spike to a single merchant, fraud type, and reason code in one answer. On a dashboard, that's more than five filter changes, two views, and a ticket.
- No new data platform: the intelligence layer sits on top of a standard lakehouse built on Amazon S3, AWS Glue, and Athena.
- Everything as code: AWS Cloud Development Kit (AWS CDK) stacks deploy the infrastructure, datasets, Topics, and agent. Synonyms and agent instructions are versioned files, not console settings.
- Security stays where it is: the agent can only reach data that the invoking user already has permission to see, so existing controls such as row-level security (RLS) and column-level security (CLS) keep applying.
What you'll build
The setup uses synthetic data for a fictional neobank, AnyCompany Bank, in eight European markets. Synthetic data gave us something important: patterns we embedded on purpose. If the AI finds them through natural-language questions alone, the approach works. If it doesn't, you know exactly where it failed.
The data covers the full cards lifecycle: customers, cards, transactions, digital wallet tokenizations, chargebacks, and merchants. The generator supports three sizes. The default S tier (1,000 customers, 50,000 transactions) generates in about 30 seconds and is enough for a demo. The L tier produces 100,000 customers, about 120,000 cards, 5 million transactions, 70,000 tokenizations, 25,000 chargebacks, and 10,000 merchants. Generation uses fixed random seeds, so every run produces the same data. On top of that, we defined six questions a PM would actually ask, each with an expected answer:
- "Why did chargebacks increase in the week of January 15th?" A 3.5x spike, 80% of it at one e-commerce merchant (FastShop Online), 85% unauthorized.
- "How long after joining should we push users to tokenize?" Spain: 3 days, UK: 7, Germany: 12 (80th percentile).
- "What type of merchants do our best customers prefer?" Metal tier goes to Travel and Restaurants, Standard tier to Groceries and Gas.
- "Is there a country where card delivery takes longer?" Germany needs 8 days (express) and 15 days (standard), the UK 5 and 10, Spain 3 and 7.
- "Do users with Christmas special cards spend more?" About 40% more, concentrated in Jewelry and Toys.
- "How many cards does a Spanish business metal user have?" 2.3 active cards versus 1.1 for personal users.
The chargeback spike, for example, is plain configuration:
1
2
3
4
5
6
7
SPIKE_START_DATE = date(2026, 1, 15)
SPIKE_END_DATE = date(2026, 1, 21)
SPIKE_MULTIPLIER = 3.5
FASTSHOP_SPIKE_SHARE = 0.80
BASELINE_TYPE_DISTRIBUTION = TypeDistribution(unauthorized=0.30, authorized=0.70)
SPIKE_TYPE_DISTRIBUTION = TypeDistribution(unauthorized=0.85, authorized=0.15)After you load the data, a validation script runs Athena queries against it and reports PASS or WARN for each pattern. For the chargeback spike, it checks that the multiplier reaches at least 80% of the 3.5x target, that FastShop Online accounts for more than 75% of the spike, and that the unauthorized and CNP shares reach at least 90% of their targets. If you're building any kind of AI analytics, we recommend this approach: known anomalies give you acceptance criteria for a system that's otherwise hard to test.
Prerequisites
To follow along, you need:
- An AWS account where you can create AWS Identity and Access Management (IAM) roles, Amazon S3 buckets, and AWS Glue, Athena, and Amazon Quick resources.
- An Amazon Quick Enterprise subscription in the deployment AWS Region, and a user with the Author Pro role who deploys and owns the resources. Q Topics and custom chat agents need this role.
- A Region where Amazon Quick supports agentic features. As of this writing, that means us-east-1, us-west-2, eu-west-1, eu-west-2, eu-central-1, ap-southeast-2, and ap-northeast-1. For the current list, refer to Supported Regions .
- Amazon Quick configured with the default service role
aws-quicksight-service-role-v0and Athena access turned on. - Node.js 22.x or later and the AWS CDK CLI. The sample was tested with CDK CLI 2.1144.0 and
aws-cdk-lib2.272.0. - Python 3.13 and the uv package manager.
- The AWS Command Line Interface (AWS CLI) with credentials for the target account, and a bootstrapped AWS CDK environment (
cdk bootstrap aws://<AWS_ACCOUNT_ID>/<AWS_REGION>).
Note: The sample is for demonstration and education. It uses synthetic data and isn't intended for production use without your own security review.
Architecture overview
Figure 1 shows the architecture. On the left is the existing lakehouse: Apache Parquet files in Amazon S3, partitioned Hive-style by country and date, the AWS Glue Data Catalog for schema and metadata, and Athena for serverless SQL. On the right is the semantic layer in Amazon Quick with three layers: datasets, Topics, and the chat agent. The PM talks only to the agent. When a Topic can't answer a question, the agent falls back to a SQL query against Athena.

Architecture
Figure 1: Architecture of the conversational analytics solution. The existing lakehouse stays unchanged; the semantic layer is added on top.
None of the lakehouse components are new, and that's the point. The plumbing took days. The interesting work happens in the semantic layer between Athena and the user, and in the instructions that turn a general-purpose large language model (LLM) into a domain analyst.
The semantic layer: teach the AI your business
Raw data speaks database. Your PM speaks business. The semantic layer translates between the two, and we spent roughly 70% of the project on it. We split it into three layers, each with one clear responsibility.
Layer 1: Pre-join your data
Don't expect the AI to figure out your star schema. We created five pre-joined datasets, each with one explicit grain: spending (one row per transaction), chargebacks (one row per dispute), tokenizations, customer cards, and card delivery. The following query defines the chargeback dataset:
1
2
3
4
5
6
7
8
9
SELECT
cb.chargeback_id, cb.chargeback_date, cb.chargeback_type, cb.reason_code,
t.transaction_id, t.transaction_date,
m.merchant_name,
m.merchant_category_name AS merchant_category,
m.channel AS merchant_channel
FROM conversational_analytics.chargebacks cb
JOIN conversational_analytics.transactions t ON cb.transaction_id = t.transaction_id
JOIN conversational_analytics.merchants m ON t.merchant_id = m.merchant_idTwo small details made a big difference. Include human-readable columns (
merchant_category_name next to the merchant category code, or MCC), so answers say "Direct Marketing" instead of "5964". And push derived logic into the dataset: the delivery dataset calculates delivery_days and filters out virtual cards, so the AI never has to know that virtual cards don't get shipped.Layer 2: Teach it your vocabulary with Topics
The sample deploys two Q Topics: one over the six raw tables for single-table questions, and an optimized Topic over the five pre-joined datasets, which we recommend for cross-table analytics. Topics map business language to data. "Spain", "Spanish", and "España" all mean
ES. "Fraud" means chargeback_type = 'unauthorized'. "Premium" and "best customers" mean membership_tier = 'metal'. We keep the synonyms in Python and generate the Topic from them, so they're version-controlled and reviewed like any other code:1
2
3
4
5
6
7
JOINED_COLUMN_SYNONYMS = {
"spending-analysis": {
"amount_cents": ["amount", "purchase amount", "spend", "spending", "total"],
"campaign_code": ["promotion", "campaign", "special edition", "XMAS", "Christmas"],
"membership_tier": ["tier", "membership", "membership level", "plan"],
},
}Synonyms compound. One mapping like "premium" →
metal unlocks a whole family of questions your PM can ask without filing a ticket.On top of the synonyms, the Topic's custom instructions describe each dataset in a structure the model can reason about: grain, contents, typical questions, and key dimensions. The following excerpt shows the entry for chargebacks:
1
2
3
4
5
6
7
8
9
3. CHARGEBACK ANALYSIS (chargeback-analysis)
- Grain: One row per chargeback dispute
- Contains: Chargeback + Transaction + Merchant data
- Use for: "Why did chargebacks increase?", "Which merchants have fraud?"
- Key dimensions: chargeback_type (unauthorized=fraud), merchant_name, reason_code
AGGREGATION GUIDANCE:
- amount_cents, chargeback_amount_cents: Use SUM for totals, AVERAGE for typical values
- days_to_tokenize, delivery_days: Use AVERAGE for typical timingKey configuration points:
- Grain first: the model has to know what one row represents before it can count or aggregate correctly.
- Example questions as routing hints: "Use for" lines tie a type of question to a dataset. When the PM asks about chargebacks, the agent picks the chargeback dataset instead of joining raw tables itself.
- Aggregation rules: without them, a model can sum delivery times or average totals. The rules make the right aggregation the default.
Note: As of this writing, Amazon Quick has moved business context into the dataset itself with Dataset Enrichment . Topics created before that change are classified as legacy Topics, and the new Topics act as a multi-dataset semantic layer with runtime joins. The sample uses legacy Topics. The principles in this post (one grain per dataset, synonyms as code, explicit aggregation rules) carry over: synonyms move into column metadata, and routing and business rules move into the dataset's custom instructions.
Layer 3: Give the agent a job description
The chat agent is where behavior lives. Its instructions consist of two versioned Markdown files that the AWS CDK stack concatenates at synth time: a persona file and a business context file.
The persona file defines who the agent is and how it behaves:
1
2
3
4
5
6
7
8
9
10
11
You are a senior data analyst for AnyCompany Bank's Cards domain. You help
Product Managers answer business questions about card transactions,
chargebacks, tokenizations, and customer behavior.
## Behavior
1. Answer using the QuickSight Q Topic first.
2. If the topic cannot answer, provide an Athena SQL query as fallback.
Briefly explain why the topic couldn't answer and what the query does.
3. Never guess. If you don't have enough information, ask the user to clarify.
4. Use business language, not database jargon.
5. Monetary values are in cents. Always divide by 100 for EUR display.Every rule addresses a failure mode we observed or expected:
- Topic first, SQL second keeps answers grounded in the curated semantic model. SQL is a fallback, not the default, and the agent has to explain why it needed it. That explanation is useful for the PM and for you when you improve the Topic.
- Never guess matters more than any other rule in a financial context. An agent that asks "Do you mean the calendar week of January 15th or the last 7 days?" builds trust. An agent that silently picks one doesn't.
- Business language means "premium customers" instead of
membership_tier = 'metal', and category names instead of MCC codes. PMs shouldn't need to know your column names. - Cents to euros sounds trivial, but it's exactly the kind of mistake that destroys credibility in the first demo.
The persona file also contains a compact glossary of business terms and metrics, and together with the Topic synonyms the sample defines more than 200 synonym mappings across 21 categories, such as customer types, membership tiers, merchant categories, time expressions, and analysis verbs. A few examples: a monthly active user (MAU) has a transaction in the past 30 days, a dormant user has none, "best customers" means the metal tier, and average transaction value (ATV) is
AVG(amount_cents) / 100. Each term comes with its synonyms, because PMs use "dispute", "CB", and "chb" interchangeably.One rule we'd put in every analytics agent: never sum pre-computed rates or averages, always recalculate from raw data. The persona states it explicitly:
1
2
**Non-additive rule:** Never SUM pre-computed rates or averages.
Always recalculate from raw data. For percentiles use APPROX_PERCENTILE.That's exactly the mistake a generic model makes and a senior analyst doesn't.
The business context file gives the agent what it needs for the SQL fallback: the Athena schema with partitions, the join paths between tables, and query guidelines (qualify table names, filter on partitions, exclude virtual cards from delivery analysis). With this context, the agent can produce a correct fallback query for a percentile question that the Topic doesn't support:
1
2
3
4
5
6
7
SELECT cu.country,
APPROX_PERCENTILE(tk.days_to_tokenize, 0.8) AS p80_days_to_tokenize
FROM conversational_analytics.tokenizations tk
JOIN conversational_analytics.cards c ON tk.card_id = c.card_id
JOIN conversational_analytics.customers cu ON c.customer_id = cu.customer_id
GROUP BY cu.country
ORDER BY p80_days_to_tokenizeFinally, the agent gets a welcome message and three starter prompts, one per flagship use case, such as "Why did chargebacks increase in the week of January 15th, 2026?". Starter prompts look like a UI detail, but they show new users what kind of questions work well, and they nudge them toward explicit dates instead of "last week".
The split matters: the Topic handles data interpretation, the persona handles behavior, and the business context handles domain expertise. When something goes wrong, you know which file to change.
How the agent reasons through a question
With all of this in place, the PM types: "Why did chargebacks increase in the week of January 15th?"
Chargebacks increased 3.5x in the week of January 15th. 85% were unauthorized transactions concentrated at a single merchant, FastShop Online, through the e-commerce channel. The primary reason code isfraud_card_not_present.
Behind that answer are five analytical steps:
- Identify the time window: "the week of January 15th" resolves to January 15 to 21.
- Compare to the baseline: chargeback volume in that week is 3.5 times the normal level.
- Drill into the merchant dimension: one merchant, FastShop Online, accounts for most of the increase.
- Check the chargeback type: 85% are unauthorized, compared to 30% in normal weeks.
- Surface the reason code: card-not-present (CNP) fraud dominates with 65%.
On a dashboard, that's five filter changes and two different views, and you have to know which filter to apply before you know the answer. The agent picks the chargeback dataset because the Topic's instructions route chargeback questions there, and it compares against a baseline because "increase" is defined as a comparison in the glossary.
From answer to action
Diagnostic answers lead to prescriptive questions. In the same conversation, the PM asks: "Draft a merchant-block request for FastShop Online." The agent produces a request scoped to the e-commerce channel, cites the evidence (85% unauthorized CNP chargebacks in the week of January 15th), and ends with "Review before submitting." The AI drafts, the human decides.
Context across questions
Because it's a conversation, follow-up questions build on each other. The PM can move from chargebacks to tokenization timing ("Spanish users tokenize within 3 days, German users take up to 12, so push cadence should differ by market"), to spending by tier, to "How many cards does a Spanish business metal user hold?" The agent keeps the thread, so the PM doesn't start over with every question. For open-ended investigations that need several angles at once, Amazon Quick Research can run an autonomous deep dive and produce a structured report.
Evaluate the agent like a colleague, not a chatbot
The embedded patterns turned evaluation from "it looks plausible" into a pass or fail check. For each use case, we knew the expected answer and could compare the agent's response with it. A few things we learned while doing this:
- Rate your confidence up front. We estimated per use case how likely the AI was to answer correctly. Chargeback root cause: 70%. A longitudinal campaign analysis (did Christmas-card users spend more after receiving the card?): only 40%. We simplified the latter to a cross-sectional comparison and kept a fallback question ready ("Show spending by card campaign") instead of forcing it.
- Relative time is the most common failure mode. "Last week" gave inconsistent results because it depends on when you ask and how the model interprets it. For demos and regression tests, use explicit dates, and use starter prompts that model explicit phrasing.
- Check the reasoning, not only the result. The right number from the wrong dataset is a bug that surfaces later. Ask the agent which dataset or query it used. Every answer should be traceable to a query.
- Keep a regression set. Re-run the demo questions after every change to instructions, synonyms, or data, and compare the answers with the expected results.
- Treat failures as semantic-layer bugs. When an answer was wrong, the fix was almost never "a better model". It was a missing synonym, a missing routing hint, or a missing aggregation rule.
What the AI handles and what needs human judgment
It's worth being explicit about the split. The AI handles the mechanical part of the analysis: selecting the dataset, applying filters, traversing joins, comparing against baselines, writing fallback SQL, and drafting a first action. Humans remain responsible for everything that requires judgment: defining the business vocabulary, deciding which use cases matter, validating results against expectations, and deciding whether a merchant actually gets blocked. The "Review before submitting" line isn't a UI detail. It's the design principle.
Deploy the agent with AWS CDK
The infrastructure is split into six AWS CDK stacks, so you can iterate on each layer independently:
| Stack | Resources |
|---|---|
ConversationalAnalytics-Infra | S3 buckets, AWS Glue database, Athena workgroup |
ConversationalAnalytics-GlueTables | AWS Glue table definitions |
ConversationalAnalytics-QuickSight | Athena data source, raw and pre-joined datasets, group |
ConversationalAnalytics-QuickSight-Topics | Q Topics (raw and optimized) |
ConversationalAnalytics-QuickSight-ChatAgent | Custom chat agent (optional) |
ConversationalAnalytics-QuickSight-Analysis | Analysis with ML anomaly detection (optional) |
We separated Topics from datasets because CloudFormation waits for the Topic refresh. When a refresh fails, which happens a lot while you iterate on custom SQL, the failure stays isolated and doesn't roll back your working datasets. All datasets use direct query mode, so Athena runs the queries and you don't need SPICE capacity.
Deploy in phases, because Topics need tables that exist and contain data:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
uv sync --frozen
cp .env.example .env.<name> # set AWS_ACCOUNT_ID, AWS_REGION, QUICKSIGHT_USER_ARN
source .env.<name>
# Phase 1: infrastructure
uv run cdk deploy ConversationalAnalytics-Infra ConversationalAnalytics-GlueTables
# Phase 2: generate, upload, and validate data
uv run python scripts/generate_data.py S
uv run python scripts/upload_to_s3.py
uv run python scripts/create_tables.py
uv run python scripts/validate_patterns.py
# Phase 3: datasets and Topics
uv run cdk deploy ConversationalAnalytics-QuickSight ConversationalAnalytics-QuickSight-Topics
# Phase 4: chat agent
uv run cdk deploy ConversationalAnalytics-QuickSight-ChatAgentThe chat agent is a native CloudFormation resource,
AWS::QuickSight::Agent , available as quicksight.CfnAgent in aws-cdk-lib. The stack loads the persona and business context files at synth time and passes them as custom instructions:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
self.chat_agent = quicksight.CfnAgent(
self,
"ChatAgent",
aws_account_id=self.account,
agent_id="conversational-analytics-cards-chat-agent",
name="Cards Analyst",
description=AGENT_DESCRIPTION,
agent_lifecycle="PUBLISHED",
welcome_message=WELCOME_MESSAGE,
starter_prompts=STARTER_PROMPTS,
custom_prompt_input=quicksight.CfnAgent.CustomPromptInputProperty(
new_prompt=quicksight.CfnAgent.CustomPromptInputParametersProperty(
custom_instructions=self.persona_instructions,
)
),
)The resource type has no permissions property, so the stack shares the agent through an
AwsCustomResource that calls UpdateAgentPermissions:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
permissions = [
{"Principal": self.quicksight_user_arn, "Actions": agent_owner_actions},
{"Principal": self.quicksight_group_arn, "Actions": ["quicksight:DescribeAgent"]},
]
self.agent_permissions = cr.AwsCustomResource(
self,
"ChatAgentPermissions",
on_create=cr.AwsSdkCall(
service="QuickSight",
action="updateAgentPermissions",
parameters={
"AwsAccountId": self.account,
"AgentId": AGENT_ID,
"GrantPermissions": permissions,
},
physical_resource_id=cr.PhysicalResourceId.of(
"conversational-analytics-chat-agent-permissions"
),
),
install_latest_aws_sdk=True,
role=self.custom_resource_role,
log_group=self.custom_resource_log_group,
)
self.agent_permissions.node.add_dependency(self.chat_agent)Configuration highlights:
install_latest_aws_sdk=Trueis required. The custom resource runs in an AWS Lambda function, and its bundled AWS SDK for JavaScript doesn't include the Agent API yet. The function installs the latest QuickSight client at deploy time, which needs internet access.custom_instructionscontains the persona and business context files. The API accepts 5 to 350,000 characters, and the stack validates the length at synth time.- Least privilege: the owner gets full permissions, and the demo group gets the viewer set
quicksight:DescribeAgentonly. Use user or group Amazon Resource Names (ARNs) as principals, not namespace ARNs. Permission sets for Topics and agents are all-or-nothing: either the viewer set or the full owner set.
Important: don't attach a
Spaces list to an agent that you create through the API. It passes validation, and then every chat message fails with a generic 400 error. We reproduced this with a stack-created space and with a known-good space created in the console, so the issue is the binding through the API, not the space. Without a space, the agent answers over the Topics and datasets that the invoking user can access. If you need a space-scoped agent, create it in the console for now.Lessons from the build
- Topics need data. Topic creation fails with
InternalFailureExceptionif referenced tables are missing or empty. Deploy in phases: infrastructure, data, datasets, Topics, agent. - Some validation messages are generic.
physicalTableMapkeys must match[0-9a-zA-Z-]*, so use hyphens, not underscores. CloudFormation reports this asAWS::EarlyValidation::PropertyValidation; the AWS CLI returns a more specific message. - Check for native resource types first. We first drove the chat agent through a custom resource, because we assumed there was no CloudFormation resource type. A security review pointed out
AWS::QuickSight::Agent. If you migrate from the custom-resource version, destroy the agent stack and redeploy, because CloudFormation creates the new agent with the same ID before it deletes the old one. - Exported analysis definitions need ISO 8601 timestamps. Serializing boto3 datetimes with
str()produces values that CloudFormation early validation rejects. Useisoformat(). - Pre-joining versus runtime joins. Pre-joined datasets worked well for our legacy Topics. With the new multi-dataset Topics, runtime joins across enriched datasets are an option, so evaluate both with your own questions.
- One thing to watch: name your Athena query results bucket
aws-athena-query-results-*. TheAWSQuicksightAthenaAccessmanaged policy is scoped to that prefix, which saves you a custom bucket policy.
"Why not paste it into a chatbot?"
If you work in financial services, someone asks this in the first five minutes. The short answer: a licensed bank can't. With this setup, the data stays in your AWS account, and every answer is traceable to the query and dataset behind it. All queries run through Athena and are logged in AWS CloudTrail : who asked what, when, and how much data was scanned. The product team owns the semantic layer, the data team owns the permissions, and the AI layer inherits RLS, CLS, and network controls instead of bypassing them. The answers are generated by an LLM and can be incomplete or wrong, even when they sound confident. Keep a human in the loop for decisions that affect customers, such as chargeback disputes, and tell users that answers are AI-generated. For a deeper look at agent isolation and approval gates, refer to Securing Amazon Quick from POC to production: Agents, Flows, and Spaces .
Clean up
To avoid ongoing charges, destroy all stacks:
1
uv run cdk destroy --allThis removes the S3 buckets including their data (they use
auto_delete_objects=True), the Athena workgroup, the AWS Glue database and tables, the Amazon Quick data source, datasets, Topics, analysis, group, and chat agent, and the inline policy on the Amazon Quick service role. A few things remain and need manual cleanup:- The Amazon CloudWatch Logs log group of the S3 auto-delete function in the infrastructure stack.
- Generated data in the local
data/folder. - The Amazon Quick subscription and its users. Per-user and account fees continue until you remove the users you added for the demo. Unsubscribing affects every user and asset in the account and Region, so coordinate with your administrator.
Takeaways
- The semantic layer is the product. A thin semantic layer gives you thin answers.
- Write the agent's instructions like a job description. Persona, behavior rules, glossary, and fallback context belong in versioned files, not in a console text box.
- Let domain experts shape it. The PMs who ask the questions are the best people to teach the AI the answers.
- Go deep on three use cases, not thirty. Three perfect answers build more trust than thirty mediocre ones.
- Embed patterns you can test against. "It seems to work" isn't an acceptance criterion.
Next up for us: more domains (payments, lending, customer lifecycle) with the same architecture and new semantic layers, proactive alerts that tell the PM before they ask, and going from insight to action with Amazon Quick Flows and Amazon Quick Automate .
The infrastructure took days. The semantic layer took weeks, and it's the part worth investing in. Start with one question your PMs ask every week, write down the expected answer, and build the semantic layer until the agent gets it right. The best dashboard is the one you never had to build.
To get started, clone the GitHub repo , deploy the S tier, and ask the agent the six demo questions.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article