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
Reference architecture
Keep the browser focused on presentation. The application server owns context interpretation, catalog access, validation and response structure.

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 --> AOne 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.
LangChain
Provides model adapters, tools, prompt composition and schema-validated structured output.
LangGraph
Runs the stateful workflow: update facts, retrieve candidates, choose a question, recommend and validate.
LangSmith
Adds traces, datasets, evaluations and regression checks without becoming the source of product truth.
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.
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"]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.
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.tsInstall 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.
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.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-referenceConfigure OpenRouter before connecting the workflow
- Create a project API key and store it only in the server environment.
- Choose a current model from the model catalog and copy its exact model identifier into REFERENCE_MODEL_ID.
- Set the OpenRouter base URL, validate all required variables at server startup and initialize the LangChain adapter shown below.
- Run the server-side structured-output smoke test before enabling the LangGraph workflow or LangSmith tracing.
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.
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.
idStable machine-readable identifier
nameDisplay name per language
summaryShort customer-facing explanation
problemsSolvedProblems the option is designed to address
bestFitPositive selection conditions
unsuitableClear non-fit conditions
capabilitiesAllowed capability claims
requirementsData, workflow or ownership prerequisites
relatedApproved alternatives for comparison
{
"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"]
}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.
Explicit beats inferred
A visitor statement replaces an earlier assumption.
Corrections are first-class
“Actually, this is for employees” changes audience without retaining the stale customer fact.
Uncertainty stays visible
“Maybe” and “not sure” should not become confirmed requirements.
Unrelated text is ignored
A side question does not silently rewrite product requirements.
Questions have memory
Record which information was requested so stale follow-ups are not repeated.
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.
- 01
Extract explicit and uncertain facts
- 02
Score plausible catalog candidates
- 03
Find the requirement that best separates them
- 04
Skip anything already answered or previously asked
- 05
Ask one concise question
- 06
Re-score after the visitor replies
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"]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.
Hard filters
Remove options that violate audience, data, policy or workflow constraints.
Candidate scoring
Score explicit need, source, audience, channel and next-action fit.
Evidence assembly
Attach the exact best-fit and requirement fields that justify each match.
Claim validation
Reject names, capabilities and integrations not present in the approved record.
Return structure the interface can trust
The application server should return a small validated contract, not unbounded presentation markup.
typequestion | recommendation | comparison | no_matchmessagecustomer-facing explanationproducts[]approved public product fieldsmatchReasons[]evidence-backed explanationssuggestions[]safe next questions
{
"type": "recommendation",
"message": "The strongest match is…",
"products": [{ "id": "customer-support-agent" }],
"matchReasons": ["Repeated customer questions"],
"suggestions": ["What data would we need?"]
}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.
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>;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 })
});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;
}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);
}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.
}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);
}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();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 });
}
}A practical reference implementation path
Define the decision
Choose one bounded customer journey and its acceptable next actions.
Model the catalog
Add fit, non-fit, capabilities, requirements and alternatives.
Build deterministic retrieval
Prove catalog boundaries before adding natural-language interpretation.
Add conversation state
Track explicit, uncertain, corrected and already-requested facts.
Add the model interface
Use it for language interpretation and explanation inside fixed contracts.
Validate every response
Block unsupported products, claims, actions and malformed structures.
Design the handoff
Carry confirmed context into an enquiry, booking, purchase or human conversation—with consent.
Test with real scenarios
Use corrections, vague requests, no-match cases, prompt attacks and service failures.
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.
- 01
Copy .env.example to a local ignored environment file and add development credentials.
- 02
Load products.json through CatalogSchema.parse so invalid records fail at startup.
- 03
Run the server and send one message to the product-discovery API.
- 04
Test a vague request, an explicit correction, a comparison, a no-match request and a simulated model outage.
- 05
Inspect the public response and confirm it contains no prompt, hidden state, trace URL or private metadata.
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.
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.
Every product and capability exists in the approved record.
Expected strong matches appear for representative needs.
Only one useful missing fact is requested, with no repeats.
Explicit corrections replace stale or inferred facts.
Unsupported needs return no_match instead of an invented product.
Timeouts and malformed output produce a transparent fallback or error.
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.
}What changes before real deployment
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.
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.
