The companion to Where to Start. Everything you need to climb the ladder one rung at a time: the product-page field guide with its prompts, three working n8n flows ready to import, the inbox-assistant pattern, the hook into your system of record, and what to have in place before you ever build an agent.
The handout — Where to Start — is the argument, and its second page is the map. This is the toolbox. It's organized as five parts that follow the ladder, and it's meant to be used in order, because each part assumes you've done the one before it. You can skip ahead. I wouldn't.
Everything in this kit was built by one person describing a problem to an AI and testing what came back. So the first decision isn't a tool or a platform — it's which AI you'll do that with. Claude, or ChatGPT with code execution. Both can read a product export, write a workflow, and explain an error. Pick the one you find yourself working best with and stay with it; switching mid-build means re-explaining your business every time.
This isn't rocket science. You don't need to know what a webhook is on day zero — I didn't. You need to describe what you want plainly, answer the questions that come back (there will be a lot in the first hour), and test what you're handed before you trust it. The partner writes the code. You make the calls. That's the whole skill, and it compounds.
Nobody sees it. It costs nothing. It can't break anything. And it's where you learn the rhythm every later rung uses: audit, decide the rules, template, generate, check. We did 7,754 pages this way across three storefronts. Here is exactly how.
Export every product as a CSV. Then hand the file to your AI assistant and ask it to audit — not to write anything yet. You are looking for the gap between what your pages say and what your data says.
…-the-bevel-is-different-from-the-201-profileThe AI will ask questions. Answer them all before it generates anything, because every answer changes thousands of pages. These are the ones that came up for us; most businesses have a version of each.
| Decision | Why it matters | Our answer |
|---|---|---|
| Whose voice? | Brand name in the header, the contact lines, the closing | Each site speaks as itself; one "made by" credit |
| Discounts on the page? | Numbers go stale; some lines are net-priced | Percent on one site, none on the distributor site, never on European carvings |
| Country of origin? | A false "Made in USA" is a legal problem; silence is safe | Claim it only where we manufacture; say nothing on imported lines |
| Phone number? | Changes when support moves to AI or a new line | On one site only |
| What is that option? | "Resin" costs 4× "Poplar" and the page didn't say why | Flexible, bends for curves, interior/exterior |
| Overwrite existing pages? | Some were hand-done — and some were wrong | Yes: consistency beats keeping 704 wrong pages |
| Unpublished / empty products? | Skip, include, or draft them | Build them; draft the ones with $0 prices |
Write the rules down and give them to the AI as a single list. Ours was a canonical table of discounts by product line, a list of which lines are domestic, the trade URL and phone per site, and the lines that must never show a trade offer. Every later prompt referred back to it. When two sources disagreed — a project note said 35%, the fleet sync said 13% — we stopped and asked instead of picking one.
Do not write 3,000 pages. Write one template for each family of products that shares a shape — crown mouldings, medallions, corbels — and let the data fill it in. A family is anything where the same questions get asked: what size, what material, what does it fit, how is it sold, how do I install it.
Every template has the same skeleton. Machines like consistency; so do customers.
Once a template is approved, the AI writes a small program that reads each product's row and fills the template: the name from the title, dimensions parsed from the title, species and prices from the variants, lead times and fit specs from tags. The prose for the family was written once; the numbers on each page are that product's own.
This is the part that makes the whole thing cheap. A price change, a new profile, a rebrand, a second storefront with the same products — export, re-run, import. We built WhiteRiver.com's 3,813 pages in about a minute after Mouldings.com's had taken an afternoon, because the generators didn't care which store the row came from.
You don't need code. Give the AI the template and the products in batches of 20 and have it write the pages directly into a spreadsheet column. Review each batch. Import.
Ask the AI to write the generator and run it. Insist on a spot-check step: it should render ten random products from each family and show you the H1, the parsed dimensions, and the spec grid before generating everything. Every parsing bug we hit — a fraction read wrong, a name doubled, a "fits" spec mistaken for a dimension — showed up in a spot-check.
These two fields are what shows in search results and what AI tools quote first. Most small catalogs have them blank. Don't write them by hand; give the AI a formula and let it fill it per product.
[Name] [Product type] | [Material or key attribute] | [Brand]
Foster Traditional Crown Moulding | Urethane | White River Hardwoods
When 500 products share the same generic name, differentiate with the number that matters: 14-3/4″ Urethane Ceiling Medallion MED1131-C | One-Piece.
[Name] [type], [dimensions]. [Two facts a buyer wants]. [One trust line].
Foster Traditional urethane crown moulding, 6-3/4″ H × 7″ P × 94-1/2″ L. Factory primed, made in USA, interior or exterior. Trade pricing available.
Leave prices out. They change; the description doesn't get updated when they do. Every stale price we found was in a description.
This is the "invisible code" part. A JSON-LD block tells any machine: this is a Product, here is its name, SKU, image, price, availability, brand. It goes either at the bottom of the product description or in your theme's product template. Theme is cleaner (one edit, every product); description is easier if you're already regenerating pages. Don't do both — a duplicate block is worse than one.
// Paste at the bottom of the product description, or in the theme's product template
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "PRODUCT NAME",
"url": "https://yourstore.com/products/HANDLE",
"sku": "SKU",
"brand": { "@type": "Brand", "name": "YOUR BRAND" },
"image": "https://…/main-image.jpg",
"description": "THE SEO DESCRIPTION",
"material": "MATERIAL",
"countryOfOrigin": "US", // only if the page says so
"offers": {
"@type": "Offer", // use "AggregateOffer" + lowPrice/highPrice for multi-variant
"priceCurrency": "USD",
"price": "118.57",
"availability": "https://schema.org/InStock" // or PreOrder for made-to-order
}
}
</script>
The schema follows the same rules as the copy. If the page doesn't claim a country of origin, the block doesn't either. If a line is net-priced, nothing in the block implies a discount. And check the url: one of our sites had schema copied from another site, telling Google the products lived on a different domain. A machine-readable claim is still a claim — and a wrong one is worse than none.
<style> blocks.ld+json. The block should be there once.A regenerated page. Every row green. "Non-critical" means Google would like optional fields — a rating, shipping details, a return policy — but nothing blocks the rich result.
A page we hadn't regenerated yet. The Product block passed the basic check but failed the stricter Merchant listing rules; the two red review items came from a reviews app emitting its own schema with a required field missing. Neither is a disaster — but you only find out by running the test.
| What happened | Why | The fix |
|---|---|---|
| A corner block linked to the wrong crown | We matched on the SKU prefix; the suffix letter was a different size | Match on the exact SKU. Always spot-check cross-links. |
| "Pediment Pediment" in a heading | The product name already contained the type word | Strip the type word before composing the heading; check ten random H1s per family. |
| A capital's width came out as 16″ | The title said "(Fits Pilasters up to 16″W)" before the real dimensions | Remove parentheticals before parsing dimensions; put "fits" in its own field. |
| A 34-foot medallion | The title had a foot mark where it meant inches | Let the page show nothing rather than a wrong number; flag it for the data owner. |
| Lead time inside the SKU field | Someone typed "SHIPS: 6-10 BUSINESS DAYS" after the SKU | Display the clean SKU, pull the lead time into a field — and fix the source, because your ERP sees that field too. |
| A resin option described as "a cast version of the same profile" | We didn't know what it was, so the AI wrote something safe and useless | Ask. It was flexible, curved-wall, exterior-rated — a real reason to pay 4×. Then patch the pages already shipped. |
| A promo band with the successor product right in it | Told the customer "buy this other thing" at the moment they saw the discount | Decide where an upsell belongs before it ships. Bottom of the page, usually. |
The pattern behind every one of these: the data was ambiguous and the AI resolved the ambiguity by itself. The fix each time was a human answer, a spot-check, or both. Budget time for questions. The AI should ask more than it assumes, and you should expect to answer a lot in the first hour and very little after.
These are real workflows from our n8n, with our company, our competitors, our addresses and our API key stripped out and a configuration node put in their place. Import one, fill in five fields, run it in test mode, read the email. That's the whole first week.
Anthropic — console.anthropic.com → API Keys. This is the model that reads and writes. Perplexity — perplexity.ai → API. This is the tool that searches the web. Both take a card; both cost cents per run at this volume.
We use Resend (resend.com): verify a domain, create an API key. In n8n go to Settings → Variables and add RESEND_API_KEY with that value. The flows read it from there so the key never sits inside a workflow. Don't have Resend? Delete the "Send via Resend" node and drop in n8n's Gmail or SMTP node — the HTML is in {{ $json.html }}.
In n8n: Workflows → ⋯ → Import from File, pick the JSON. It opens with a sticky note on the canvas that repeats these instructions.
Click the Anthropic Chat Model node → Credential → create with your key. Same for the Perplexity node. Red warning triangles on nodes mean a credential is missing.
Every flow ships with testMode = true, which sends only to testEmail. Fill in Workflow Configuration, click Execute Workflow, read what arrives. When it's right, set testMode to false and toggle the workflow Active.
Every Monday it researches each company on your list — yours first — with one focused web search apiece, then combines the findings into a single branded email with your company's card highlighted at the top. Our team reads it before the week starts and nobody ran a search.
companies | A list. Your company first — the flow treats the first entry as the benchmark. Then 3–5 competitors. More than five and the email gets long. |
competitorWebsites | One URL per name, spelled exactly as in companies. The agent starts each search from the site. |
industry | One phrase — "decorative wood wall panels," "commercial HVAC service," whatever you sell. Every search is scoped to it, which is what keeps the results on topic. |
emailRecipient · fromAddress | Comma-separated recipients; a verified sender. |
testMode · testEmail | Ships on. Turn off after the first read. |
| 1 | Weekly Schedule | Fires Monday at 9. Change day/hour here; the flow doesn't care what day it is. |
| 2 | Workflow Configuration | Every setting in one place. Nothing else in the flow needs editing. |
| 3 | Split Out Companies | Turns the list into one item per company so each gets its own search. |
| 4 | Prepare Company List | Attaches each company's website from the config. |
| 5 | Research Agent | Claude, with Perplexity as its only tool. The system prompt tells it to research ONE company, stay inside your industry, and never mention the others. Retries 4× if the model is busy. |
| 6 | Combine All Reports | Waits for all companies, then gathers the reports into one item. |
| 7 | Format Final Summary | Plain JavaScript, no AI. Builds the HTML email, sorts your company first, drops off-topic results, adds the date range. Brand colors are at the top of the code — swap them for yours. |
| 8 | Send via Resend | Posts the email. Reads the key from $vars.RESEND_API_KEY. |
Three mornings a week it sends the team a short newsletter: upcoming events from the trade associations your people attend, in the states that matter to you, plus two trend questions about your category — each trend with a source link. The agent returns strict JSON; a code node turns it into the email. No AI touches the formatting.
companyName · industry | Who you are; what you sell, in a phrase. |
associations | The trade groups whose chapter events your team would actually go to — "ASID and NKBA" for us. Use yours. |
states | Where those events matter. Five is plenty. |
trendTopicA · trendTopicB | Two questions the agent researches each run. Ours: trends in mouldings and millwork; where tambour panels are showing up. Make them specific to what your customers ask about. |
emailRecipient · fromAddress · testMode | Same as Flow A. |
| 1 | Schedule Trigger | Cron 0 7 * * 1,3,5 — 7am Mon/Wed/Fri. Change the days there. |
| 2 | Workflow Configuration | All settings. Also stamps today's date for the agent. |
| 3 | Trend Agent | Claude + Perplexity. Runs three searches — events, trend A, trend B — and must return one JSON object in an exact shape. The prompt forbids inventing URLs: every source must be one Perplexity returned, and an empty list beats a made-up link. |
| 4 | Code | Finds the JSON in the agent's output, parses it, and builds the branded email — summary, events table, trends with clickable source domains. Pure formatting. |
| 5 | Send via Resend | Same send node as Flow A. |
| You see | It means | Do |
|---|---|---|
| Email arrives, sections filled, links resolve | Working | Turn off testMode, activate |
| Red triangle on a model or Perplexity node | Credential not connected | Click the node → Credential → select yours |
| "Overloaded" or 529 in the Research Agent | Model busy | Nothing — the retry handles it. If it fails four times, run again later |
| Email arrives with "No content was generated" | Agent output wasn't parseable | Open the agent node's output; usually a prompt edit broke the JSON shape |
| Results about the wrong industry | industry field too vague | Make it a phrase a stranger would understand |
| Send node errors with 401 | RESEND_API_KEY variable missing or wrong | Settings → Variables; the value is the whole key |
| Nothing arrives, no error | testMode on and testEmail wrong, or the workflow isn't Active | Check both |
Every flow that touches your store — the sync checker, order status, draft orders, trade sign-ups — needs a key that lets n8n talk to Shopify. That key comes from a custom app you create yourself in the store admin. No developer account, no App Store, no cost. One per store; we have three.
X-Shopify-Access-Token). Reference it from the Shopify node or an HTTP Request node. Never paste the token into a workflow.Thirty-two scopes. Don't start here. Each one was added the week a flow needed it, and each addition meant reinstalling the app and rotating the token. That's the right friction.
/admin/api/2024-10/…). Unpinned calls break on Shopify's quarterly release.| Rung | Add these scopes | So that |
|---|---|---|
| 2 | read_orders · read_products · read_customers · read_inventory · read_fulfillments · read_draft_orders | Flows can see orders, catalog and stock. The sync checker lives here. |
| 3 | write_draft_orders · write_customers | The inbox assistant and the quote agents can create draft orders and trade accounts. |
| 4 | write_orders · read_price_rules · write_discounts · read_locations | Agents apply trade discounts and route by fulfillment location. |
There's no file to import here, because the assistant is built around your inbox, your products, and your rep's voice. What I can give you is the design that made it safe enough to turn on — and the one rule that matters more than the rest of it combined.
The inbox assistant reads an email that arrives for a rep, works out what kind of request it is, looks up whatever real data it needs, and writes a reply in the rep's voice — into the rep's own Drafts folder, with a note on top saying what it checked and what to verify. Then it stops. It cannot send. Only the rep can.
That single constraint is the difference between an assistant a sales team trusts and one they turn off in a week. Nothing reaches a customer without a person reading it first. The rep is always the sender, the voice, and the final approval. The assistant preps the shot; the rep takes it.
| 1 | Email arrives | A trigger polls the rep's inbox (Microsoft Graph for Outlook; Gmail has its own node). A list of senders to skip — internal, newsletters, automated — keeps it from drafting replies to itself. |
| 2 | Classify | A model reads the email and returns one label: order status, quote request, catalog request, trade application, policy question, or "not for me." JSON out, nothing else. |
| 3 | Look up real data | Each label has its own branch that fetches facts — order and tracking from the ERP hook (Part 4), stock and price for a SKU, the trade application on file. If the lookup finds nothing, the branch says so. |
| 4 | Draft in the rep's voice | A model writes the reply using only the looked-up facts and two or three of the rep's own past emails as a voice sample. The draft opens with an assistant note: what was checked, what to verify, delete before sending. |
| 5 | Save as draft. Tag. Stop. | The draft lands in the rep's Drafts folder. The original email gets a category tag — Assistant-Drafted, Skipped, or Urgent — so you can measure coverage later. The workflow ends. |
| Drafts only | The workflow has no send node. Not a setting — a structural absence. |
| No invented numbers | Prices and stock come from a lookup or don't appear. Asked to quote a product that didn't exist, ours refused and flagged it for a human. That was the day I trusted it. |
| Inbox only | It works the mail that comes in. Outbound, calls, and outreach stay with the rep. |
| Claims stay accurate | Same rules as the pages: origin only where true, net-priced lines never discounted. The rules list from Part 1 is the rules list here. |
| Everything is tagged | So coverage is measured, not assumed. After two weeks you know what share of the inbox it handles and which drafts go out unchanged. |
| One config node | Rep name, mailbox, voice samples, territory, skip list — all in one place. Mirroring to the next rep is a copy and ten minutes. |
Shopify is where our customers order. NetSuite is where the truth is — stock, lead times, whether a box actually left the building. For a year our agents could see the first and not the second, so every "is it in stock" question went to a human. This part is how we fixed that in one afternoon, for nothing, and why it's the most reusable thing we've built.
The mistake I almost made was bolting a NetSuite lookup into one agent's workflow. Six agents, six copies, six things to drift. Instead it's one n8n workflow any agent can call with a small JSON request and get back a sentence to say plus the facts behind it. The agent reads the answer. The workflow decides what the answer is.
Five values into n8n's Variables, and NetSuite is done. You never log into it again for this.
defer_to_human and a sentence to say while capturing a callback.Each of these cost five to twenty minutes. Together they're the difference between "it works" and "it works first try for the next person." Your ERP will have its own list; finding them is the same every time — the tool returns the raw error, you read it, you change one line.
| What happened | Why | The fix |
|---|---|---|
| "Connection cannot be established" | I'd copied the EIN into the Account ID variable. They sit side by side on Company Information. | Account ID is the number under Time Zone. Check the URL the workflow builds first. |
| NetSuite answered 200, workflow said error | NetSuite replies as vnd.oracle.resource+json; n8n leaves the body as a string. | One line: parse the string if the object isn't there. Now in the flow. |
| Order 22411 not found | Shopify orders arrive as SO407551 with the web number in PO# — as #22411, hash included. | Match on tranid, SO+number, or PO# with or without the hash. |
| "Record trackingnumbermap was not found" | The tracking table I expected isn't exposed to SuiteQL. Neither was my second guess. | They roll up onto the sales order in linkedtrackingnumbers. No join needed. |
| Status came back as "G" | SuiteQL returns sales-order status as a code. | BUILTIN.DF(status) gives the label; strip its "Sales Order :" prefix. |
| Consumer key vanished | The key shows once. I closed the tab. | Reset Credentials on the Integration record — which also kills every token under it. Reissue the token. |
| Agent said "let me check" twice | The prompt said it, then the platform's pre-tool speech said it again. | One owner for the filler: tool pre-tool speech on force, line deleted from the prompt. |
| Agent kept using the old Shopify order tool | Two tools could answer "where's my order"; the model picked whichever fit the moment. | One tool per question. Remove the other from the agent; don't just prefer it in the prompt. |
Webhook → normalize → validate → build and sign the query → one HTTP call → interpret rows into a sentence and a data object → respond. Invalid requests never reach the ERP; they get a "what's the order number?" sentence instead. Every branch ends with the same fields — found, defer_to_human, needs_input, spoken, data — so every agent reads every answer the same way. Swap the query builder and the interpreter and it's a Dynamics, SAP B1, or Acumatica hook.
Hit the webhook from a terminal with a real SKU, a made-to-order item, a bogus one, then an order with the right ZIP and the wrong ZIP. Read the raw JSON. Only when those five come back right do you add the tool to an agent — then run the same five through the agent's test panel and listen.
Test the send without sending. A RESEND_TEST_TO variable, while it exists, routes every tracking email to you. Deleting it is the go-live switch — put that on a checklist.
Any agent POSTs {"action":"item_availability","sku":"ABC-123","qty_needed":10} and gets a sentence to say and the facts behind it. Also order_status, tracking, send_tracking (emails tracking to the address on the order) and invoice_balance. Written for NetSuite's SuiteQL; the two nodes that know that are marked.
NS_ACCOUNT_ID | Company Information → the number under Time Zone, not the EIN. |
NS_CONSUMER_KEY · NS_CONSUMER_SECRET | From the Integration record, shown once on save. |
NS_TOKEN_ID · NS_TOKEN_SECRET | From the Access Token, shown once. Issued under a long-tenured user with the read-only role. |
RESEND_API_KEY · RESEND_FROM_ORDERS · RESEND_REPLY_TO | For send_tracking. Sender on your verified domain; reply-to a mailbox a person reads. |
BRAND_NAME · BRAND_PHONE · BRAND_URL | Used in the tracking email. Defaults are obvious placeholders. |
RESEND_TEST_TO | Optional. While it exists, every tracking email goes here. Delete to go live. |
| 1 | Webhook | POST /erp-lookup. Flat body or an ElevenLabs tool call. |
| 2 | Normalize Input | Cleans fields, strips "order"/"#" from order numbers, validates what each action needs. |
| 3 | Valid Request? | Bad requests skip the ERP entirely and get a "what's the order number?" sentence from Bad Request Reply. |
| 4 | Build Query + Sign · NetSuite-specific | One SuiteQL query per action, and the OAuth 1.0 signature (HMAC-SHA256) NetSuite wants. Tunables at the top. |
| 5 | ERP SuiteQL | The single HTTP call. Never errors — the next node reads the status. |
| 6 | Interpret Result · NetSuite-specific | Rows → sentence + data. Low-stock threshold, made-to-order types, identity check and the email template live here. |
| 7–9 | Needs Email? · Resend · Confirm Send | send_tracking only. Sends to the address on the order, then confirms or defers. |
| 10 | Respond | Always the same shape: found, defer_to_human, needs_input, reason, spoken, data. |
ElevenLabs → agent → Tools → Webhook. Name it erp_lookup, POST to the production URL, parameters action, sku, qty_needed, order_number, email, zip, plus constants channel = voice and agent = its name. Give each agent only the actions it should have. In the prompt: read spoken word for word; on defer_to_human, capture a callback; on needs_input, ask and call again. Our tool JSON is in the download.
netsuite_error_401 | Token revoked, credentials reset, or the user lost the role. |
netsuite_error_403 | The role is missing a permission. Compare to step 2. |
netsuite_error_400 | A table or column NetSuite doesn't expose to you. error_detail names it. |
| "Connection cannot be established" | Account ID wrong; check the URL in Build Query + Sign. |
This part isn't how to build a voice agent. That's a longer document and a longer year. This is what has to already be true before you try — because every one of these is something we had to go back and fix after the agents were live.
An agent on the phone gets asked "does it come in walnut," "what's the lead time," "is it in stock," "what's my trade price." If the answers aren't in structured fields a lookup can reach, the agent guesses or stalls. Part 1 is not optional before Part 5, and Part 4 is what makes "is it in stock" answerable at all. Ours guessed on a discount for months because the page and the prompt disagreed.
Discounts by product line. Which lines are net. Where origin can be claimed. What the agent must never say "done" about until a tool succeeds. We keep one canonical table; every agent prompt and every page derives from it. When the table changed and one agent didn't, a customer heard the wrong number.
During business hours our reps get sixty seconds of first refusal on every call. The agents catch what the reps can't reach; after hours they carry it alone. Decide this before the first call, and tell the team. "Collaborate, don't replace" is a design constraint, not a slogan.
When an agent transfers to a person, the person hears the context — not a cold ring. When an agent transfers to another agent, the opener is a greeting, not "Sure thing" to a sentence nobody said. Test every transfer path yourself, on a real phone, before a customer does.
A speech-to-text slip turned "Ryse Construct" into "Arise Construction" on a $24K quote. Every agent now spells company names back phonetically. Proper nouns are a business risk; treat them like one.
Six agents answering around the clock produce more conversation in a week than a manager can read in a month. We gave the reading job to a seventh agent: every Monday it reads every transcript and flags what a manager would flag — pricing ambiguity, failed transfers, outcomes logged as success that weren't. It recommends; a human decides. In its first week it cleared an agent of a complaint. Build supervision in before you need it.
Everything in this kit was built by one person directing an AI, at a 62-person company, with tools that cost less per month than a single agency invoice. If you build something from it, tell me — the address is on the handout. And if something in here is wrong or unclear, tell me that first.
White River Hardwoods · AI Team · Fayetteville, Arkansas · Share freely. The three workflow files, this kit, the handout, and the full build journal all live at whiteriver.com/pages/ai-talk.