Ting Blueprint · 001

Build an AI Product Discovery Agent

A practical technical reference for a catalog-grounded agent that understands customer needs, asks contextual questions and returns validated product recommendations.

Difficulty
Intermediate
Estimated build time
2–4 days
Architecture type
Stateful catalog retrieval
Updated
July 2026
Language
English
On this page
01 · Reference scope

What this Blueprint covers

The reference focuses on the public capability pattern: understand a need, preserve useful context, search an approved catalog, explain matches and guide the next action.

Included

  • Conversation UI and turn contract
  • Fictional catalog structure
  • Context correction rules
  • Contextual question selection
  • Catalog-grounded matching
  • Structured product responses
  • Deterministic fallback
  • Isolation and validation boundaries

Not included

  • Ting’s real website agent
  • Production prompts or ranking
  • Private integrations
  • Real customer or CRM data
  • Ting’s production provider choices
  • Production security implementation details
02 · Architecture

Reference architecture

Keep the browser focused on presentation. The application server owns context interpretation, catalog access, validation and response structure.

Visual workflow from a customer conversation through contextual understanding, approved catalog retrieval, validated recommendations and product cards with a next action.
A simplified reference workflow; the implementation diagrams and contracts below define the technical behavior.
MermaidReference architecture
01Conversation UI
02Application Server
03Conversation State
04Intent & Requirements
05Approved Local Catalog
06Retrieval & Match
07Validation Layer
08Structured Response
View Mermaid source
flowchart LR
    A["Conversation UI"] --> B["Application Server"]
    B --> C["Conversation State"]
    B --> D["Intent & Requirement Extraction"]
    D --> E["Approved Local Catalog"]
    E --> F["Retrieval & Match Layer"]
    F --> G["Validation Layer"]
    G --> H["Structured Response"]
    H --> A
02.1 · Reference stack

One practical stack for building the reference

The Blueprint uses OpenRouter, LangChain, LangGraph and LangSmith to make the implementation concrete. These are educational reference choices, not a statement about Ting’s production stack.

OpenRouter

A model gateway for the reference model interface. Select a model that supports the language, latency and structured-output needs of your project.

Official documentation

LangChain

Provides model adapters, tools, prompt composition and schema-validated structured output.

Official documentation

LangGraph

Runs the stateful workflow: update facts, retrieve candidates, choose a question, recommend and validate.

Official documentation

LangSmith

Adds traces, datasets, evaluations and regression checks without becoming the source of product truth.

Official documentation

Approved catalog / data store

A versioned JSON catalog works locally. A production system can use an approved database or business data source behind the same validated repository interface.

Web Hoster / Cloud Infrastructure

Runs the server-side API, injects secrets securely, exposes health checks and supports logging, monitoring and rollback.

MermaidReference stack flow
01Conversation UI
02Application API
03LangGraph Workflow
04LangChain Tools
05OpenRouter
06Approved Catalog
07LangSmith
08Web Hoster / Cloud Infrastructure
View Mermaid source
flowchart LR
    UI["Conversation UI"] --> API["Application API"]
    API --> GRAPH["LangGraph Workflow"]
    GRAPH --> CHAIN["LangChain Tools and Structured Output"]
    CHAIN --> ROUTER["OpenRouter"]
    GRAPH --> CATALOG["Approved Product Catalog"]
    GRAPH --> TRACE["LangSmith Tracing and Evaluation"]
    API --> HOST["Web Hoster / Cloud Infrastructure"]
02.2 · Project structure

Separate orchestration from product truth

Keep state, workflow nodes, catalog access, the public API and evaluation cases in distinct modules. This makes catalog boundaries testable without calling a model.

Reference project treeTEXT
src/
  agent/
    graph.ts
    state.ts
    nodes/
      extract-facts.ts
      retrieve-catalog.ts
      choose-question.ts
      recommend.ts
      validate-response.ts
  catalog/
    products.json
    schema.ts
    retrieve.ts
  api/
    product-discovery.ts
  evals/
    dataset.json
    recommendation.test.ts
02.3 · Installation and configuration

Install the reference packages and configure server-only secrets

Start with a current JavaScript or TypeScript server project. Package versions change, so use the supported versions for your runtime and review each framework’s migration notes.

Install packagesSHELL
npm install @langchain/core @langchain/langgraph @openrouter/ai-sdk-provider ai langsmith zod

# Add your application framework and test runner separately.
# Keep credentials server-side.
.env.exampleENV
# .env.example — placeholders only
OPENROUTER_API_KEY=replace_with_server_side_secret
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
REFERENCE_MODEL_ID=choose_for_your_own_requirements
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=replace_with_server_side_secret
LANGSMITH_PROJECT=product-discovery-reference

Configure OpenRouter before connecting the workflow

  1. Create a project API key and store it only in the server environment.
  2. Choose a current model from the model catalog and copy its exact model identifier into REFERENCE_MODEL_ID.
  3. Set the OpenRouter base URL, validate all required variables at server startup and initialize the LangChain adapter shown below.
  4. Run the server-side structured-output smoke test before enabling the LangGraph workflow or LangSmith tracing.

OpenRouter API keysOpenRouter model catalog

agent/model.ts — OpenRouter through LangChainTYPESCRIPT
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { z } from "zod";

const ExtractedFactsSchema = z.object({
  facts: z.array(z.object({
    key: z.string().min(1),
    value: z.string().min(1),
    certainty: z.enum(["explicit", "uncertain", "inferred"])
  }))
});

const ModelEnvironment = z.object({
  OPENROUTER_API_KEY: z.string().min(1),
  OPENROUTER_BASE_URL: z.string().url(),
  REFERENCE_MODEL_ID: z.string().min(1)
}).parse(process.env);

export const openRouter = createOpenRouter({
  apiKey: ModelEnvironment.OPENROUTER_API_KEY,
  baseURL: ModelEnvironment.OPENROUTER_BASE_URL
});

// Use structured output for interpretation and explanation only.
// Deterministic retrieval and catalog validation remain authoritative.
export async function extractFacts(prompt: string) {
  return generateObject({
    model: openRouter(ModelEnvironment.REFERENCE_MODEL_ID),
    schema: ExtractedFactsSchema,
    prompt,
    temperature: 0
  });
}

export async function smokeTestReferenceModel() {
  const result = await generateObject({
    model: openRouter(ModelEnvironment.REFERENCE_MODEL_ID),
    schema: z.object({ status: z.literal("ready") }),
    prompt: "Return status=ready. Do not include any other field."
  });
  return result.object.status === "ready";
}
  • Never expose API keys in browser code.
  • Commit .env.example, not a populated environment file.
  • Choose your own model based on language, structured output, latency, privacy and cost requirements.
  • Keep the local deterministic path available when the model interface is unavailable.
03 · Catalog model

Give each product enough truth to support a decision

A useful catalog needs more than a name and description. It should say when a product fits, when it does not, what it can do and what it requires.

id

Stable machine-readable identifier

name

Display name per language

summary

Short customer-facing explanation

problemsSolved

Problems the option is designed to address

bestFit

Positive selection conditions

unsuitable

Clear non-fit conditions

capabilities

Allowed capability claims

requirements

Data, workflow or ownership prerequisites

related

Approved alternatives for comparison

Example catalog recordJSON
{
  "id": "customer-support-agent",
  "name": "Customer Support Agent",
  "summary": "Answers repeat customer questions",
  "problemsSolved": ["Repeated enquiries"],
  "bestFit": ["Approved support content"],
  "unsuitable": ["No reliable answer source"],
  "capabilities": ["Approved-answer retrieval"],
  "requirements": ["Escalation rules"],
  "related": ["internal-knowledge-agent"]
}
04 · Conversation state

Store facts, not a vague transcript summary

Treat the transcript as evidence. Derive a compact set of visitor facts with certainty and source, and prefer explicit corrections over previous assumptions.

01

Explicit beats inferred

A visitor statement replaces an earlier assumption.

02

Corrections are first-class

“Actually, this is for employees” changes audience without retaining the stale customer fact.

03

Uncertainty stays visible

“Maybe” and “not sure” should not become confirmed requirements.

04

Unrelated text is ignored

A side question does not silently rewrite product requirements.

05

Questions have memory

Record which information was requested so stale follow-ups are not repeated.

05 · Follow-up logic

Ask for the single most useful missing fact

The next question is selected from the current need, facts already collected and the requirements that separate the strongest candidates.

question value = candidate separation × decision relevance × answerability
  1. 01

    Extract explicit and uncertain facts

  2. 02

    Score plausible catalog candidates

  3. 03

    Find the requirement that best separates them

  4. 04

    Skip anything already answered or previously asked

  5. 05

    Ask one concise question

  6. 06

    Re-score after the visitor replies

MermaidTurn logic
01Visitor Message
02Update Facts
03Correct Context
04Score Candidates
05Ask or Recommend
06Validate Claims
07Structured Result
View Mermaid source
flowchart TD
    A["Visitor Message"] --> B["Update Explicit Facts"]
    B --> C["Correct Previous Context"]
    C --> D["Score Catalog Candidates"]
    D --> E{"Enough Evidence?"}
    E -- "No" --> F["Ask One Useful Missing Question"]
    E -- "Yes" --> G["Validate Catalog Claims"]
    G --> H["Return Answer + Product Cards + Next Actions"]
06 · Retrieval & matching

Ground every recommendation in approved catalog evidence

Use deterministic filters for hard boundaries, then a transparent match layer for ranking. Validate every returned claim against the selected catalog records.

1

Hard filters

Remove options that violate audience, data, policy or workflow constraints.

2

Candidate scoring

Score explicit need, source, audience, channel and next-action fit.

3

Evidence assembly

Attach the exact best-fit and requirement fields that justify each match.

4

Claim validation

Reject names, capabilities and integrations not present in the approved record.

07 · Response contract

Return structure the interface can trust

The application server should return a small validated contract, not unbounded presentation markup.

  • type question | recommendation | comparison | no_match
  • message customer-facing explanation
  • products[] approved public product fields
  • matchReasons[] evidence-backed explanations
  • suggestions[] safe next questions
Example responseJSON
{
  "type": "recommendation",
  "message": "The strongest match is…",
  "products": [{ "id": "customer-support-agent" }],
  "matchReasons": ["Repeated customer questions"],
  "suggestions": ["What data would we need?"]
}
07.1 · Runnable contracts and workflow

Build the agent as small validated units

The following TypeScript reference shows the critical contracts. Add imports and project-specific helpers where indicated, then test every node independently before compiling the graph.

catalog/schema.tsTYPESCRIPT
import { z } from "zod";

export const ProductSchema = z.object({
  id: z.string().min(1),
  name: z.string().min(1),
  summary: z.string().min(1),
  problemsSolved: z.array(z.string()),
  bestFit: z.array(z.string()),
  unsuitable: z.array(z.string()),
  capabilities: z.array(z.string()),
  requirements: z.array(z.string()),
  related: z.array(z.string()),
  cta: z.object({ label: z.string(), href: z.string() })
});

export const CatalogSchema = z.array(ProductSchema).min(1);
export type Product = z.infer<typeof ProductSchema>;
agent/state.tsTYPESCRIPT
import { Annotation } from "@langchain/langgraph";

export type Fact = {
  key: string;
  value: string;
  certainty: "explicit" | "uncertain" | "inferred";
  sourceTurn: number;
};

export const DiscoveryState = Annotation.Root({
  messages: Annotation<Array<{ role: "user" | "assistant"; content: string }>>({
    reducer: (left, right) => left.concat(right), default: () => []
  }),
  facts: Annotation<Record<string, Fact>>({
    reducer: (current, updates) => ({ ...current, ...updates }), default: () => ({})
  }),
  askedFactKeys: Annotation<string[]>({
    reducer: (left, right) => [...new Set([...left, ...right])], default: () => []
  }),
  candidateIds: Annotation<string[]>({ default: () => [] }),
  response: Annotation<DiscoveryResponse | null>({ default: () => null })
});
agent/facts.ts — corrections and certaintyTYPESCRIPT
export function mergeFacts(
  current: Record<string, Fact>,
  extracted: Fact[]
) {
  const next = { ...current };

  for (const fact of extracted) {
    const previous = next[fact.key];
    const visitorCorrectedIt = fact.certainty === "explicit" &&
      previous && previous.value !== fact.value;

    if (!previous || visitorCorrectedIt ||
        (previous.certainty === "inferred" && fact.certainty !== "inferred")) {
      next[fact.key] = fact;
    }
  }

  return next;
}
catalog/retrieve.ts — deterministic candidatesTYPESCRIPT
import catalogJson from "./products.json";
import { CatalogSchema, type Product } from "./schema";

const catalog = CatalogSchema.parse(catalogJson);

export function retrieveProducts(facts: Record<string, Fact>): Product[] {
  const terms = Object.values(facts)
    .filter((fact) => fact.certainty !== "inferred")
    .flatMap((fact) => fact.value.toLowerCase().split(/\s+/));

  return catalog
    .filter((product) => !violatesHardBoundary(product, facts))
    .map((product) => ({
      product,
      score: [...product.problemsSolved, ...product.bestFit, ...product.capabilities]
        .join(" ").toLowerCase().split(/\s+/)
        .filter((word) => terms.includes(word)).length
    }))
    .filter(({ score }) => score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
    .map(({ product }) => product);
}
agent/questions.ts — one useful missing factTYPESCRIPT
export function chooseQuestion(
  candidates: Product[],
  facts: Record<string, Fact>,
  askedFactKeys: string[]
) {
  const missing = candidateRequirements(candidates)
    .filter((requirement) => !facts[requirement.key])
    .filter((requirement) => !askedFactKeys.includes(requirement.key))
    .map((requirement) => ({
      ...requirement,
      value: separationScore(requirement, candidates) * requirement.decisionWeight
    }))
    .sort((a, b) => b.value - a.value);

  return missing[0] ?? null; // Ask one question, or move to recommendation.
}
agent/response.ts — public contract and claim guardTYPESCRIPT
export const DiscoveryResponseSchema = z.object({
  type: z.enum(["question", "recommendation", "comparison", "no_match", "error"]),
  message: z.string().min(1).max(1200),
  products: z.array(ProductSchema.pick({
    id: true, name: true, summary: true, capabilities: true, cta: true
  })).max(3),
  matchReasons: z.array(z.string()).max(6),
  suggestions: z.array(z.string()).max(3)
});

export function validateCatalogClaims(response: DiscoveryResponse, catalog: Product[]) {
  const allowed = new Map(catalog.map((product) => [product.id, product]));
  for (const product of response.products) {
    const source = allowed.get(product.id);
    if (!source || product.name !== source.name) throw new Error("Unsupported product");
    if (product.capabilities.some((item) => !source.capabilities.includes(item))) {
      throw new Error("Unsupported capability");
    }
  }
  return DiscoveryResponseSchema.parse(response);
}
agent/graph.ts — LangGraph assemblyTYPESCRIPT
import { END, START, StateGraph } from "@langchain/langgraph";

export const discoveryGraph = new StateGraph(DiscoveryState)
  .addNode("extractFacts", extractFacts)
  .addNode("retrieveCatalog", retrieveCatalog)
  .addNode("chooseQuestion", chooseQuestionNode)
  .addNode("recommend", recommend)
  .addNode("validate", validateResponse)
  .addEdge(START, "extractFacts")
  .addEdge("extractFacts", "retrieveCatalog")
  .addConditionalEdges("retrieveCatalog", routeAfterRetrieval, {
    ask: "chooseQuestion", recommend: "recommend", noMatch: "validate"
  })
  .addEdge("chooseQuestion", "validate")
  .addEdge("recommend", "validate")
  .addEdge("validate", END)
  .compile();
api/product-discovery.ts — server endpointTYPESCRIPT
export async function POST(request: Request) {
  try {
    const input = RequestSchema.parse(await request.json());
    const result = await discoveryGraph.invoke({
      messages: [{ role: "user", content: input.message }],
      facts: input.state.facts,
      askedFactKeys: input.state.askedFactKeys
    });

    return Response.json(PublicResultSchema.parse({
      response: result.response,
      state: { facts: result.facts, askedFactKeys: result.askedFactKeys }
    }));
  } catch (error) {
    console.error("product_discovery_request_failed", safeErrorCode(error));
    return Response.json({
      response: deterministicFallback(),
      error: "The assisted response is temporarily unavailable."
    }, { status: 503 });
  }
}
08 · Build sequence

A practical reference implementation path

01

Define the decision

Choose one bounded customer journey and its acceptable next actions.

02

Model the catalog

Add fit, non-fit, capabilities, requirements and alternatives.

03

Build deterministic retrieval

Prove catalog boundaries before adding natural-language interpretation.

04

Add conversation state

Track explicit, uncertain, corrected and already-requested facts.

05

Add the model interface

Use it for language interpretation and explanation inside fixed contracts.

06

Validate every response

Block unsupported products, claims, actions and malformed structures.

07

Design the handoff

Carry confirmed context into an enquiry, booking, purchase or human conversation—with consent.

08

Test with real scenarios

Use corrections, vague requests, no-match cases, prompt attacks and service failures.

08.1 · Run locally

Prove the bounded journey before deployment

Use the fictional catalog until the behavior is stable. The local flow should work with a deterministic fallback even when the external model interface is disabled.

  1. 01

    Copy .env.example to a local ignored environment file and add development credentials.

  2. 02

    Load products.json through CatalogSchema.parse so invalid records fail at startup.

  3. 03

    Run the server and send one message to the product-discovery API.

  4. 04

    Test a vague request, an explicit correction, a comparison, a no-match request and a simulated model outage.

  5. 05

    Inspect the public response and confirm it contains no prompt, hidden state, trace URL or private metadata.

09 · Safety boundaries

Keep the reference implementation and production system separate

Catalog isolation

Use a dedicated fictional or approved public catalog; never query production data by default.

Server ownership

Keep model interfaces, validation and business-system access on the application server.

No hidden-state exposure

Return only public response fields; do not send prompts, private metadata or internal reasoning.

Input limits

Bound message length, history size, accepted roles and request origins.

Transparent failure

Use a deterministic path or show a temporary error. Never present an invented successful response.

Consent-aware handoff

Do not turn an educational conversation into a lead without a clear visitor action and consent.

09.1 · Evaluation and regression

Test decisions, not just fluent answers

Use LangSmith traces and datasets to inspect workflow behavior, then keep deterministic assertions in the codebase so critical catalog boundaries do not depend on manual review.

Catalog grounding

Every product and capability exists in the approved record.

Recommendation fit

Expected strong matches appear for representative needs.

Question quality

Only one useful missing fact is requested, with no repeats.

Correction handling

Explicit corrections replace stale or inferred facts.

No-match honesty

Unsupported needs return no_match instead of an invented product.

Resilience

Timeouts and malformed output produce a transparent fallback or error.

evals/recommendation.test.tsTYPESCRIPT
const cases = [
  { input: "We need answers from approved support articles", expected: ["customer-support-agent"] },
  { input: "Actually, this is for employees", correction: { audience: "employees" } },
  { input: "Recommend a payroll system", expectedType: "no_match" }
];

for (const testCase of cases) {
  const result = await discoveryGraph.invoke(toInitialState(testCase.input));
  assertCatalogGrounded(result.response, catalog);
  assertNoRepeatedQuestion(result.askedFactKeys);
  assertExpectedOutcome(result.response, testCase);
  // Record the run in a LangSmith dataset for trace review and regression trends.
}
10 · Production checklist

What changes before real deployment

Catalog ownership and update workflow
Permissions and audience rules
Evaluation set for recommendation quality
Response-schema validation
Abuse and unsupported-request testing
Human handoff and exception ownership
Observability without exposing sensitive content
Failure and rollback behavior
Accessibility and bilingual QA
Business-system integration review
10.1 · Deploy it

Move the tested workflow to a Web Hoster / Cloud Infrastructure

Deploy the application API and workflow to a server runtime that supports your traffic, region and data requirements. Keep the public UI separate from secrets and business-system credentials.

Runtime

Run the API and LangGraph workflow server-side. Keep requests stateless or load conversation state from an approved store.

State

Use signed client state only for low-risk demos. For production, persist minimal conversation state with retention and access rules.

Catalog

Assign an owner, version records, validate updates and provide a rollback path.

Secrets

Inject credentials through the hosting environment. Never bundle them into browser JavaScript or logs.

Controls

Add origin checks, authentication where needed, rate limits, request-size limits and timeouts.

Observability

Collect safe operational logs and LangSmith traces with sensitive-content controls and retention rules.

Reliability

Expose health checks, alert on failure rates, define fallback behavior and test rollback.

Integration

Connect CRM, booking, commerce or human handoff only after explicit consent and business-rule validation.

11 · Limits

What this reference deliberately does not solve

This Blueprint names one educational reference stack, but it does not reveal or claim Ting’s production providers or production model. It does not include Ting’s prompts, scoring, private integrations or security implementation. Your production design still depends on the catalog, risk, workflow, volume, languages and systems of the specific business.