1. Why ScreenJSON for AI
Screenplays are notoriously difficult training and retrieval data. Parsing a PDF screenplay into "scene 14, spoken by MARA, reacting to the previous line" is a research project on its own. Every team that has ever tried to build a narrative-aware AI product has either done that project badly, or paid dearly to do it well.
ScreenJSON is the format that was always supposed to exist: structural from the start, typed, UUID-keyed, language-aware, and open.
- Every scene is a typed object with a stable UUID.
- Every element (action, dialogue, cue, parenthetical, shot, transition) is a typed node with a stable UUID.
- Every character is an indexed entity referenced by UUID, not by string.
- Every text field is a language map so multilingual retrieval is first-class.
- A dedicated
analysisblock exists specifically for derived data — embeddings, passages, summaries — that won't contaminate the canonical document.
2. Generating embeddings
Embeddings live under analysis.embeddings, grouped by
the UUID of their target (a scene, element, or character). Each
embedding records its model, dimensions, source field, language,
token count, and creation timestamp so you can reproduce or
invalidate the index later.
{
"analysis": {
"embeddings": {
"<scene-uuid>": [{
"id": "<embedding-uuid>",
"model": "text-embedding-3-large",
"dimensions": 1536,
"values": [0.0234, -0.0417, ...],
"source": "text",
"lang": "en",
"tokens": 420,
"created": "2026-01-14T10:30:00Z"
}]
},
"settings": {
"model": "text-embedding-3-large",
"size": 512,
"overlap": 64,
"tokeniser": "cl100k"
}
}
} A minimal Node.js generator, one embedding per scene:
import fs from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import OpenAI from 'openai';
const client = new OpenAI();
const doc = JSON.parse(await fs.readFile('screenplay.json', 'utf8'));
doc.analysis ??= { embeddings: {}, settings: {
model: 'text-embedding-3-large',
size: 512, overlap: 64, tokeniser: 'cl100k',
}};
for (const scene of doc.document.scenes) {
const text = scene.body
.filter((el) => el.text?.en)
.map((el) => el.text.en)
.join('\n');
if (!text) continue;
const res = await client.embeddings.create({
model: 'text-embedding-3-large',
input: text,
});
doc.analysis.embeddings[scene.id] = [{
id: randomUUID(),
model: 'text-embedding-3-large',
dimensions: res.data[0].embedding.length,
values: res.data[0].embedding,
source: 'text',
lang: 'en',
tokens: res.usage.total_tokens,
created: new Date().toISOString(),
}];
}
await fs.writeFile('screenplay-embedded.json', JSON.stringify(doc, null, 2)); For catalogues, do this in Greenlight as a pipeline step so the embedding pass retries on failure and parallelises across workers. See Greenlight.
3. Vector store integrations
The canonical document stays in your document store (Postgres, MongoDB, DynamoDB, Elasticsearch, S3). The vectors go into a vector store. At query time, retrieve top-k from the vector store, then look up the referenced scene and element UUIDs in the canonical document for context and provenance.
3.1 PostgreSQL + pgvector
Best choice if you already run Postgres and want one fewer
moving part. Install the pgvector extension.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE screenjson_embeddings (
id uuid PRIMARY KEY,
document_id uuid NOT NULL,
scene_id uuid,
element_id uuid,
source text NOT NULL,
lang text NOT NULL DEFAULT 'en',
model text NOT NULL,
tokens integer,
created_at timestamptz NOT NULL DEFAULT now(),
embedding vector(1536) NOT NULL
);
-- HNSW index for cosine similarity
CREATE INDEX ON screenjson_embeddings
USING hnsw (embedding vector_cosine_ops);
-- Filter-friendly secondary indexes
CREATE INDEX ON screenjson_embeddings (document_id);
CREATE INDEX ON screenjson_embeddings (scene_id); import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL!);
for (const [sceneId, list] of Object.entries(doc.analysis.embeddings)) {
for (const e of list) {
await sql`
INSERT INTO screenjson_embeddings
(id, document_id, scene_id, source, lang, model, tokens, embedding)
VALUES (${e.id}, ${doc.id}, ${sceneId},
${e.source}, ${e.lang}, ${e.model}, ${e.tokens},
${JSON.stringify(e.values)}::vector)
ON CONFLICT (id) DO NOTHING
`;
}
} Query:
SELECT scene_id, document_id,
1 - (embedding <=> $1::vector) AS similarity
FROM screenjson_embeddings
WHERE document_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 8; 3.2 ChromaDB
The simplest path for prototypes and single-node deployments. Chroma runs embedded or as a server.
import chromadb, json, pathlib
client = chromadb.PersistentClient(path=".chroma")
col = client.get_or_create_collection("screenjson")
doc = json.loads(pathlib.Path("screenplay.json").read_text())
for scene_id, embeds in doc["analysis"]["embeddings"].items():
for e in embeds:
col.upsert(
ids=[e["id"]],
embeddings=[e["values"]],
metadatas=[{
"document_id": doc["id"],
"scene_id": scene_id,
"source": e["source"],
"lang": e["lang"],
"model": e["model"],
}],
)
# Query
hits = col.query(
query_embeddings=[query_vec],
n_results=8,
where={"document_id": doc["id"]},
) 3.3 Weaviate
Strong choice for mixed hybrid search (vector + keyword) with a schema-first model that mirrors ScreenJSON naturally.
import weaviate, weaviate.classes as wvc
client = weaviate.connect_to_local()
col = client.collections.create(
name="ScreenJSON",
vectorizer_config=wvc.Configure.Vectorizer.none(),
properties=[
wvc.Property(name="document_id", data_type=wvc.DataType.UUID),
wvc.Property(name="scene_id", data_type=wvc.DataType.UUID),
wvc.Property(name="element_id", data_type=wvc.DataType.UUID),
wvc.Property(name="source", data_type=wvc.DataType.TEXT),
wvc.Property(name="lang", data_type=wvc.DataType.TEXT),
wvc.Property(name="text", data_type=wvc.DataType.TEXT),
],
)
for scene_id, embeds in doc["analysis"]["embeddings"].items():
for e in embeds:
col.data.insert(
properties={
"document_id": doc["id"],
"scene_id": scene_id,
"source": e["source"],
"lang": e["lang"],
"text": " ".join(x["text"]["en"] for x in
next(s for s in doc["document"]["scenes"]
if s["id"] == scene_id)["body"]
if x.get("text")),
},
uuid=e["id"],
vector=e["values"],
) 3.4 Pinecone
Managed, low-maintenance, and scales past the point where running your own is worth the effort.
import { Pinecone } from '@pinecone-database/pinecone';
const pc = new Pinecone();
const index = pc.index('screenjson');
const batch = [];
for (const [sceneId, list] of Object.entries(doc.analysis.embeddings)) {
for (const e of list) {
batch.push({
id: e.id,
values: e.values,
metadata: {
document_id: doc.id,
scene_id: sceneId,
source: e.source,
lang: e.lang,
model: e.model,
},
});
}
}
await index.upsert(batch);
const hits = await index.query({
vector: queryVec,
topK: 8,
filter: { document_id: doc.id },
includeMetadata: true,
}); 4. Retrieval-augmented generation
The ScreenJSON-shaped RAG loop:
- Chunk. Default to scene-level chunks — every
scene becomes one passage. For very long scenes, split at
element boundaries and use
overlapto keep context continuity. - Embed and store. Embed each chunk's
concatenated text. Store in the vector DB keyed by passage
UUID, with
document_id,scene_id, andelement_idin the metadata. - Retrieve. Top-k cosine similarity against the user query, filtered by document or catalogue as needed.
- Hydrate. For each hit, look up its scene and element UUIDs in the canonical document. Pull the heading, cast, and the exact element text.
- Prompt. Pass the LLM a small set of passages with their structural context, and ask for a structured answer with citations.
The hydration step looks like this:
type Hit = { scene_id: string; element_id?: string; similarity: number };
function hydrate(doc: ScreenJSONDocument, hits: Hit[], lang = 'en') {
return hits.map((hit) => {
const scene = doc.document.scenes.find((s) => s.id === hit.scene_id);
if (!scene) return null;
const element = hit.element_id
? scene.body.find((el) => el.id === hit.element_id)
: undefined;
return {
similarity: hit.similarity,
heading: scene.heading,
cast: scene.cast.map((id) =>
doc.characters.find((c) => c.id === id)?.name).filter(Boolean),
text: element
? element.text?.[lang]
: scene.body
.filter((el) => el.text?.[lang])
.map((el) => el.text[lang])
.join('\n'),
refs: { scene: scene.id, element: element?.id },
};
}).filter(Boolean);
} The system prompt:
You are analysing a screenplay in ScreenJSON format.
Every scene has an `id`; every element inside a scene body has an `id`.
When you cite text, return the scene id AND the element id.
Never invent ids that are not in the input.
Respond as JSON:
{
"answer": string,
"citations": [
{ "scene": uuid, "element": uuid, "excerpt": string }
]
} 5. MCP server mode
The CLI can expose itself as a Model Context Protocol server, letting any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Continue, or your own agent — pick up ScreenJSON as a first-class tool surface.
# Run over stdio (for local desktop clients)
screenjson mcp --stdio
# Or as an HTTP server (for remote agents)
screenjson mcp --http --port 7777 Exposed tools:
screenjson.open(path)— load a ScreenJSON document into the session.screenjson.convert(input, format?)— import from FDX, Fountain, FadeIn, or PDF.screenjson.export(doc, format)— export back to any supported format.screenjson.validate(doc)— schema-validate a document.screenjson.query(doc, jq)— run ajqexpression against the document.screenjson.search(query, k?)— semantic search across the current catalogue using pre-computed embeddings.screenjson.scene(doc, id)— fetch a single scene with its hydrated cast and heading.screenjson.character(doc, id)— fetch a character with their dialogue across the document.
Claude Desktop config:
{
"mcpServers": {
"screenjson": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "/absolute/path/to/scripts:/data:ro",
"screenjson/cli", "mcp", "--stdio"
]
}
}
} Claude Code / Cursor (HTTP):
{
"mcpServers": {
"screenjson": {
"url": "http://localhost:7777/mcp"
}
}
} With the server attached, an agent can do things like "summarise every night exterior in this shooting script", "list every scene where MARA and ELLIS are both present", or "find the moment of greatest emotional shift in Act II" — and cite the specific scene UUIDs and element UUIDs it drew from.
6. Citations & hallucination control
Because every scene and element has a stable UUID, every claim an LLM makes about a screenplay can be verified against the source document. Make the model return citations, then validate every UUID:
function verifyCitations(doc: ScreenJSONDocument, response: LLMResponse) {
for (const c of response.citations) {
const scene = doc.document.scenes.find((s) => s.id === c.scene);
if (!scene) return { ok: false, reason: 'unknown scene uuid' };
if (c.element) {
const el = scene.body.find((e) => e.id === c.element);
if (!el) return { ok: false, reason: 'unknown element uuid' };
if (c.excerpt && !el.text?.en?.includes(c.excerpt.slice(0, 24))) {
return { ok: false, reason: 'excerpt does not match element text' };
}
}
}
return { ok: true };
} Any failure is a hallucination and can be refused automatically. Successful citations can be rendered with screenjson-ui — each cited span highlighted against the actual screenplay.