json-for-llm
v1.0.9
Published
Standardized JSON response format for LLM consumption with sensitive field filtering - NestJS + Prisma plugin
Maintainers
Readme
json-for-llm
A NestJS plugin that provides standardized JSON endpoints for LLM consumption. When an LLM agent sends a request with X-LLM-Ready: true, it receives structured data instead of HTML — without the agent needing to know your internal API shape.
Design Philosophy
Two rules, strictly enforced:
- The plugin never touches your database. It calls data-fetching functions you provide (
fetcher), not Prisma directly. Your services remain the only place where database queries live. - Adding new content types requires zero code changes. Drop a config entry, wire up the fetcher — done.
Features
- Schema-agnostic: No coupling to
schema.prismamodel names - Sensitive field filtering: Strips passwords, tokens, API keys, etc. before responding
- Standardized response format: Every content type returns the same
LLMResponsestructure - Frontend route interception: Agents use the same URLs as humans; no separate API paths needed
- Nginx template included: One-line configuration for the reverse proxy
Installation
npm install json-for-llmRequires NestJS ≥ 10 and a @Global() PrismaModule in your application (needed only for the Prisma fallback mode — see below).
Quick Start
1. Wire up the module
// app.module.ts
import { LlmProxyModule } from 'json-for-llm';
import { ArticlesService } from './modules/articles/articles.service';
import { EventsService } from './modules/events/events.service';
import { PersonsService } from './modules/persons/persons.service';
@Module({
imports: [
// ... your other modules
LlmProxyModule.register({
backendUrl: 'http://localhost:3000',
models: [
{
route: 'articles',
fetcher: (slug) => articlesService.findOne(slug),
format: 'article',
},
{
route: 'events',
fetcher: (slug) => eventsService.findOne(slug),
format: 'event',
},
{
route: 'people',
fetcher: (slug) => personsService.findOne(slug),
format: 'profile',
},
// Add more content types here — no other code changes needed
],
}),
],
})
export class AppModule {}2. Update nginx
Forward X-LLM-Ready: true requests to NestJS:
location / {
if ($http_x_llm_ready = "true") {
proxy_pass http://backend_api;
}
try_files $uri $uri/ /index.html;
}3. Done
LLM agents now get structured JSON from your existing URLs:
# Human request → HTML page (unchanged)
curl https://your-site.com/articles/my-post
# Agent request → structured JSON (new)
curl -H 'X-LLM-Ready: true' https://your-site.com/articles/my-post
# → {"llm":{...},"title":"...","content":{...},"metadata":{...}}Auto-Registration (autoRegister)
If you prefer zero-configuration, LlmProxyModule.autoRegister() scans your running NestJS application and automatically discovers all content routes — no manual model list needed.
// app.module.ts (after bootstrap)
const llmModule = await LlmProxyModule.autoRegister(app, {
backendUrl: 'http://localhost:3000',
enableMiddleware: true,
});
@Module({
imports: [
// ... other modules
llmModule,
],
})
export class AppModule {}autoRegister inspects your application's internal module resolver to find all controllers with slug-parameter routes (e.g. /articles/:slug). For each discovered route it:
- Infers the route segment from the controller name (
ArticlesController→'articles') - Finds the best matching service method (
findOne,findBySlug,getById, etc.) - Wires a
fetcherthat calls the method with the slug - Infers the
formatfrom the route name
Internal routes (/api/, /llm/, /health) are skipped automatically.
Privacy: @LLMSensitive Decorator
Beyond field-name-based filtering, you can mark specific DTO/entity fields with the @LLMSensitive() decorator to ensure they are always excluded from LLM responses — even if their name is not in the default blocklist.
import { LLMSensitive } from 'json-for-llm';
class PersonDto {
name: string;
email: string;
department: string;
@LLMSensitive({ description: 'HR-only field' })
performanceReview: string;
@LLMSensitive()
flaggedForReview: boolean;
}Fields marked with @LLMSensitive() are removed from responses automatically, regardless of their name. The description option is stored in metadata for audit purposes but does not affect filtering behavior.
To use the decorator, ensure reflect-metadata is imported once in your application entry point:
// main.ts
import 'reflect-metadata';How It Works
LLM Agent nginx NestJS
───────── ───── ──────
GET /articles/my-post
+ X-LLM-Ready: true ──► if header=true
proxy_pass ─────────────► LlmProxyMiddleware
│
/articles/my-post ◄── matches route
─────────────────────────► articlesService.findOne('my-post')
│
▼
LLMResponse JSON ◄── buildLLMResponse()
←─────────────────┘
←────────────────────────────────────────────────────────────── {"llm":{...},"title":"..."}Middleware intercepts all frontend URLs. If X-LLM-Ready: true is present, the request is handled by the plugin. Otherwise it passes through to your normal page rendering.
Configuration
LlmProxyModule.register(options)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| backendUrl | string | — | Required. Your backend base URL. |
| models | ModelConfig[] | — | Required. Content types to expose. |
| headerName | string | 'x-llm-ready' | HTTP header that triggers the LLM response. |
| contentType | string | 'application/llm+json' | Response Content-Type. |
| cacheControl | string | 'private, max-age=3600' | Cache-Control header value. |
| enableMiddleware | boolean | true | Whether to intercept frontend routes. |
| maxContentLength | number | 3000 | Truncate content after this many characters. |
| wordsPerMinute | number | 500 | Reading speed used to compute reading_time_seconds. |
| excludeFields | string[] | [password, token, ...] | Additional fields to strip from responses. |
ModelConfig
| Field | Type | Description |
|-------|------|-------------|
| route | string | Required. URL path segment (e.g., 'articles'). Empty string '' for root-level pages. |
| fetcher | (slug: string) ⇒ Promise<any> | Preferred. Your service method. Return null for 404. |
| model | string | Legacy. Prisma model name. Only used when fetcher is absent. |
| slugField | string | Legacy. Database field for slug lookups (default: 'slug'). |
| include | Record<string,any> | Legacy. Prisma include config for relations. |
| select | Record<string,any> | Legacy. Prisma select config. |
| format | 'article' \| 'event' \| 'profile' \| 'job' \| 'publication' \| 'page' | Normalizes metadata extraction for this type. |
| transform | (data: any) ⇒ any | Reshape raw data before building the LLMResponse. |
| excludeFields | string[] | Per-model field exclusions (merged with global). |
| interceptFrontendRoutes | boolean | Set false to only expose via /llm/:model/:slug API. |
Response Format
All responses follow this structure:
{
llm: {
version: string; // always "1.0.0"
format: string; // 'article' | 'event' | 'profile' | 'job' | 'publication' | 'page'
generated_at: string; // ISO 8601
ttl_seconds: number; // 3600
},
title: string;
url: string; // absolute URL
summary: string; // first 500 chars of description/excerpt
content: {
type: 'markdown';
text: string; // truncated to maxContentLength
footnotes: string[];
},
entities: any[]; // e.g. speakers for events, authors for publications
metadata: {
author?: string;
published_at?: string;
modified_at?: string;
tags: string[];
word_count: number;
reading_time_seconds: number;
language: 'zh' | 'en';
},
links: { url: string; text: string; internal: boolean }[];
images: { url: string; alt: string; title?: string }[];
}Per-Model Transform
Use transform to reshape your service's response into the fields the plugin expects:
{
route: 'articles',
fetcher: (slug) => articlesService.findOne(slug),
format: 'article',
transform: (article) => ({
title: article.titleZh || article.title,
summary: article.summary || article.excerpt || '',
content: article.content || '',
author: article.author?.name,
tags: article.tags?.map(t => t.name),
links: [
...(article.relatedLinks || []),
{ url: article.sourceUrl, text: 'Source', internal: false },
],
images: article.images?.map(img => ({ url: img.url, alt: img.alt })),
})),
}The transform output is passed through the sensitive-field filter before building the final response.
Sensitive Field Filtering
Two mechanisms work together to protect sensitive data:
1. Default blocklist (field-name based)
The following fields are automatically excluded from all responses (unless explicitly allowed via excludeFields):
password, passwd, pwd, creditCard, creditCardNumber, cvv, cvc, ssn, socialSecurityNumber, secret, token, apiKey, privateKey, secretKey, casdoorId, authProvider
2. @LLMSensitive decorator (per-field, metadata-based)
Mark specific DTO fields with @LLMSensitive() to exclude them regardless of name. See Privacy: @LLMSensitive Decorator above.
Nginx Template
A ready-to-use nginx configuration is included:
cat node_modules/json-for-llm/dist/templates/nginx.conf.templateKey rule — forward LLM requests to NestJS:
location / {
if ($http_x_llm_ready = "true") {
proxy_pass http://backend_api;
}
try_files $uri $uri/ /index.html;
}Why No Prisma Coupling?
The first version of this plugin directly called PrismaClient[modelName].findUnique(). This meant your schema.prisma model names had to match the config exactly, and changing your schema broke the plugin.
The current version avoids this entirely. You provide a fetcher function — any async function that takes a slug and returns data (or null). The plugin formats the response; your services own the database. Your schema is yours to evolve.
The only reason PrismaMode (model/include/select) still exists is as a shortcut for simple projects that don't have a service layer yet. For production use, fetcher is the recommended approach.
API Endpoints
When enableMiddleware: true (default), no separate API endpoints are needed — agents use the same URLs as human visitors.
For direct API access (optional):
| Method | Path | Description |
|--------|------|-------------|
| GET | /llm/:model/:slug | Fetch content by model name + slug |
| GET | /llm/page/:slug | Fetch content with empty route prefix |
| GET | /llm/health | Health check |
All endpoints require the X-LLM-Ready: true header.
License
MIT
