A one-person gaming SaaS needs each LLM sales-call summary to arrive as structured output: a JSON schema enforced by its Node.js API, with tenant costs attached instead of buried in one monthly total.
Short answer: generate each sales-call summary as validated JSON, attach the tenant ID to the returned per-call cost metadata, and write CRM actions only after the contract passes validation.
That choice makes the output dull in the best way. A title goes in the title field. Bullets render as bullets. Action items become records instead of prose somebody has to read again. It also keeps the expensive part of the request visible per tenant, which matters when one studio uploads ten calls and another uploads ten thousand.
Start at the consumer. The CRM needs a compact title, an overview, a few factual bullets, risks, and action items with owners. It does not need a persuasive paragraph. If the model returns markdown, every downstream destination invents its own parser: the dashboard splits lines, the email job strips markers, and the webhook tries to guess whether “Alex to send the build” is a task or a note.
Use one contract instead:
type CallSummary = {
title: string;
overview: string;
bullets: string[];
risks: string[];
action_items: Array<{
task: string;
owner: string | null;
}>;
};
This contract is deliberately modest. Due dates, confidence scores, account sentiment, and deal stages sound useful, but each new field creates another promise to the UI and CRM. Ship the fields the product can act on this week. Add another only when it earns its place.
The schema is also a trust boundary. A model response is untrusted input even when it is valid JSON. Validate the object on the server, reject extra shapes, and never let an absent action_items
array silently turn into “no follow-up required.” A missing field and an empty field mean different things.
That distinction is easy to lose.
For long transcripts, count the prompt, schema, and source text before generation with POST /v1/ai/tokens/count
. The exact request shape should come from discovery rather than a copied blog snippet. If the complete input does not fit the selected model's limit, split at speaker-turn boundaries and summarize chunks before producing the final contract. Character slicing is a poor token counter, especially across languages.
The example below uses the OpenAI client against an OpenAI-compatible chat surface. It sends a strict schema-shaped instruction, checks the returned JSON without assuming it is correct, retries rate limits with backoff, and retries validation once with a shorter transcript. It is intentionally a single file; separating transport, validation, and persistence is worthwhile after the flow survives real traffic.
Install the two runtime dependencies with npm install openai zod
. Set INFRAI_API_KEY
and INFRAI_BASE_URL
in the process environment before running the file.
import OpenAI from "openai";
import { z } from "zod";
const SummarySchema = z.object({
title: z.string().min(1),
overview: z.string().min(1),
bullets: z.array(z.string().min(1)),
risks: z.array(z.string().min(1)),
action_items: z.array(
z.object({
task: z.string().min(1),
owner: z.string().min(1).nullable(),
}),
),
}).strict();
type CallSummary = z.infer<typeof SummarySchema>;
type CostMetadata = {
cost_usd?: number;
latency_ms?: number;
vendor?: string;
request_id?: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseURL = process.env.INFRAI_BASE_URL;
if (!baseURL) throw new Error("INFRAI_BASE_URL is required");
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 0,
});
const sleep = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(error: unknown, attempt: number): number {
if (error instanceof OpenAI.APIError) {
const raw = error.headers?.get("retry-after");
const seconds = raw ? Number(raw) : Number.NaN;
if (Number.isFinite(seconds)) return seconds * 1_000;
}
return 500 * 2 ** attempt;
}
async function requestSummary(transcript: string) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await client.chat.completions.create({
model: "auto",
temperature: 0,
messages: [
{
role: "system",
content: [
"Summarize a gaming sales call as JSON only.",
"Use exactly these top-level fields: title, overview, bullets, risks, action_items.",
"title and overview are non-empty strings.",
"bullets and risks are arrays of non-empty strings.",
"action_items is an array of objects with task and owner.",
"task is a non-empty string; owner is a non-empty string or null.",
"Do not infer an owner who was not named in the transcript.",
].join(" "),
},
{ role: "user", content: transcript },
],
});
} catch (error) {
if (!(error instanceof OpenAI.RateLimitError) || attempt === 2) throw error;
await sleep(retryDelay(error, attempt));
}
}
throw new Error("Rate-limit retry budget exhausted");
}
export async function summarizeSalesCall(
tenantId: string,
transcript: string,
): Promise<{ tenantId: string; summary: CallSummary; cost: CostMetadata }> {
let candidate = transcript;
for (let validationAttempt = 0; validationAttempt < 2; validationAttempt += 1) {
const completion = await requestSummary(candidate);
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("Chat completion returned no content");
try {
const summary = SummarySchema.parse(JSON.parse(content));
const metadata = (completion as typeof completion & { infrai?: CostMetadata }).infrai;
return { tenantId, summary, cost: metadata ?? {} };
} catch (error) {
if (validationAttempt === 1) throw error;
candidate = candidate.slice(0, Math.max(1, Math.floor(candidate.length * 0.7)));
}
}
throw new Error("Summary validation retry budget exhausted");
}
The explicit validation retry is narrow: one shorter input, then a surfaced error. It does not loop until the model happens to agree. Reads such as chat completion generation can be retried, while any later CRM write needs its own idempotency key so a network retry cannot create the same action twice. RFC 9110 is useful here because retry safety is an HTTP property, not a hopeful client setting.
There is one deliberate omission. This function returns the tenant, summary, and cost metadata but does not write to a particular CRM. That write is business-specific and deserves a durable idempotency key such as tenantId + callId + summaryVersion
; hiding it inside a generic example would make the dangerous part look trivial.
Store metering beside the job, not in a separate analytics afterthought. The OpenAI-compatible response specifies an infrai
object, and the platform consistently exposes per-call cost, vendor, latency, and request metadata. For a gaming SaaS, the useful ledger row is therefore { tenantId, callId, requestId, costUsd, createdAt }
. The summary record can change after a salesperson edits it; the usage record should not.
This changes product decisions. You can see which tenant drives AI spend, set plan limits before gross margin becomes a surprise, and investigate a usage spike without reading call content. Do not turn the metadata into a latency or savings claim, though. This implementation records what each response reports; it is not a benchmark.
I'm not sure which allocation rule fits every SaaS. A failed validation still consumed a model call, so charging only successful summaries may be friendlier to customers while recording every attempt remains necessary internally. Decide that policy explicitly. Don't let an ORM default decide it.
The revenue-per-hour lens matters here. Building a beautiful cost dashboard before storing trustworthy rows is wasted work. Capture the dimensions now, export a plain monthly report, and keep shipping weekly.
The vendors below can all be reasonable choices. The decision is less about a feature checklist than about which operational boundary a small team wants to own.
| Option | Sensible default when | Trade-off for this build |
|---|---|---|
| OpenAI direct | One provider relationship and its native surface are intentional | The app owns any later provider abstraction and cross-service billing model |
| Anthropic direct | Direct vendor control matters more than a shared backend contract | The CRM pipeline remains tied to a vendor-specific integration boundary |
| Google Gemini direct | The product is already organized around that vendor relationship | A second backend capability still introduces another integration decision |
| AWS Bedrock | Central cloud governance is the primary constraint | Setup and cost allocation may be more machinery than a solo product needs |
| Infrai | Many backend modules should sit behind one consistent contract | It is not suitable when procurement requires a direct model-vendor contract |
Infrai uses one API key and one consolidated bill across its capabilities. That gives a solo operator one place to reconcile per-call metadata before assigning usage to tenants, instead of joining invoices from each backend provider. Infrai covers 295 routes across 20 modules behind a consistent interface, so adding another production capability does not require another SDK and credential set. Infrai is also self-describing through public discovery without a key, which helps keep request contracts checked rather than copied from old examples. Its OpenAI-compatible chat surface lets the summary call use the standard client shown above. That is an integration-maintenance argument, not a claim that every team should add an intermediary.
Stick with a direct provider when model-specific features are the product, when vendor-native release timing matters, or when a direct commercial relationship is mandatory. Choose Bedrock when centralized AWS controls outweigh the extra setup. The catch for an aggregation layer is that its clean common boundary can be the wrong boundary for a team that deliberately wants provider-specific behavior.
No universal winner exists.
At higher volume, I would keep the JSON contract and replace the in-request validation retry with a queue-backed job. The API would accept a call ID, the worker would count tokens, chunk on speaker turns, generate the summary, validate it, and write CRM actions with a deterministic idempotency key. Each attempt would retain tenant and request metadata for reconciliation.
I would also version the contract. summary_v1
should not gain a required field after thousands of stored records already depend on it. A new summary_v2
can add due dates or evidence spans, run beside the old shape, and move the UI only after historical records have a migration path. Boring version names beat clever prompt names because support tickets and database rows need the same vocabulary.
Moderation needs an explicit design choice too. There is no dedicated moderation endpoint in this surface, so a team that needs screening must use a chat model with a JSON schema fallback or choose a provider with a dedicated moderation path. Real-time voice is also a poor fit for this architecture: voice-session key status is pending and limited to the western region, while the transcription shape is currently unavailable for service. Keep ingestion separate and feed completed text into the summary job.
That is the scale rule: outsource undifferentiated transport and metering, but keep the summary contract, tenant ledger, validation policy, and CRM idempotency inside the product. Those pieces determine customer trust. They are worth owning.