At KandidAI I spent most of my time on an AI shopping assistant for Shopify brands. Getting a demo working took very little: a model, some product data and a chat box. Getting it to a point where merchants trusted it with real carts and orders, on three different channels, took a lot longer. This post covers the parts that mattered most. I've left out merchant data and anything proprietary.
One agent, three channels
Shoppers reach the agent through the storefront chat widget, WhatsApp and Instagram. All three share the same merchant knowledge and the same commerce tools, but they behave very differently.
On the website I can stream text as it's generated, show product cards and keep UI state in the browser. WhatsApp and Instagram are webhook-based. They have their own delivery rules, their own way of identifying users and their own media formats. Early on I treated them as thin transports over the same chat logic, and every channel-specific quirk turned into a special case in the agent code.
What worked better was one shared runtime with an adapter per channel:
Channel event
-> identity and conversation resolution
-> normalized agent input
-> shared runtime and tools
-> structured response events
-> channel-specific rendering and deliveryThe runtime doesn't know which channel it's talking to. Each adapter deals with acknowledgements, retries, formatting and whatever the platform expects.
Getting knowledge in
Answer quality depends on what's been ingested before the shopper ever types anything. Merchants need a dependable way to sync products, collections, policy pages, files, FAQs and brand guidelines.
I learned to give that pipeline visible states, something like queued, processing, indexed, failed and stale. A lot of the "the bot gave a wrong answer" reports I looked at weren't model problems at all. A document had failed to index and nobody could see it.
It also helps to know where each retrieved chunk came from. When an answer is weak, you want to trace it back to the missing, outdated or contradictory source instead of guessing.
Tools decide what the agent can actually do
I don't let the model change commerce state directly. It picks from a set of typed tools, and each tool validates its input and applies the rules. Typical ones are product search, variant lookup, cart operations, order status and handing off to a person.
A tool should return more than data. A simplified version of the shape I ended up with:
type ToolResult<T> = {
ok: boolean;
data?: T;
userMessage?: string;
retryable?: boolean;
audit?: Record<string, string>;
};With this, the runtime can retry a temporary failure without pretending something worked. Read-only lookups go straight through, while anything that changes a cart needs stricter confirmation. Every change can be traced back to the conversation and tool call that made it.
Memory with limits
Conversation history is useful, but keeping all of it makes every call slower, more expensive and noisier. I keep recent context separate from the facts worth remembering long term about a shopper or merchant. That long-term memory should be visible and easy to remove.
Cross-channel memory raises a harder question: is this WhatsApp contact the same person as that website visitor? I only link identities when the platform or the merchant's own data gives a solid reason to, never because two conversations look alike.
Handoff to a human
A handoff can't just be a polite "someone will get back to you". In our system it's a change of state. When it triggers, we record why, keep the transcript and tool context, notify the right people (within the store's working hours), and change how the bot replies while a human has the conversation.
The triggers I found useful were the shopper asking for a person, tools failing repeatedly, sensitive policy questions, and cases where a wrong guess would be expensive.
Tracing everything
To debug the agent in production I need to see one shopper turn end to end: what was retrieved, which prompt and config were active, which tools ran and what they returned, where the time went, and whether a channel retried or duplicated an event. We use Langfuse for this. A transcript alone never explained why the bot recommended the wrong product or repeated an action.
The questions I ask of a trace:
- Which knowledge sources were retrieved?
- Which prompt and configuration were active?
- Which tools were called, and what did they return?
- Where did the time go?
- Did the channel retry or send the same event twice?
- Did a human get involved?
What actually helped
Looking back, swapping models didn't give us the biggest improvements. They came from work around the model: strict tool contracts, reliable ingestion, channel-specific delivery, explicit handoff states, tracing, and giving merchants proper controls. The model still matters, but most of my time went into the rest of the system.