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.
Don't want to write the loader yourself? The free
screenjson-db-importer
writes ScreenJSON straight into pgvector, Chroma, Weaviate, or
Pinecone, one record per scene, line, or character, with any
embeddings already in analysis.embeddings stored in
the database's native vector field. The recipes below are for when
you want your own tables.
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. Agents over MCP
screenjson-server is a
Model Context Protocol
server. Connect Claude Code, Claude Desktop, Cursor or your own
agent to /mcp and a whole library of screenplays,
stored in your own NoSQL database, becomes a set of tools the agent
can both read and edit.
claude mcp add --transport http screenjson http://127.0.0.1:8080/mcp \
--header "Authorization: Bearer $TOKEN" Other clients take the same URL and header:
{
"mcpServers": {
"screenjson": {
"url": "http://127.0.0.1:8080/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
} Tools the agent gets:
list_documents,search— find scripts, scenes and lines by title, full text or vector.get_outline,read_scene— read a script's structure, then only the scenes it needs.edit_element,insert_element,delete_node— change individual lines.insert_scene,edit_scene_heading,move_node— restructure.checkout,release— hold a scene while reworking it.import_screenjson,export_screenjson,run_export_job— bring scripts in, get them out as JSON or PDF.api_request— anything else in the REST API, such as batch checkouts for a library-wide pass.
Many agents, one library
Each agent uses its own token, and every call goes through the same API as a human editor's. Writes to a script are applied in order and validated against the schema; an edit based on a stale read is refused rather than overwriting someone else's; a checked-out scene is off limits to everyone else. So you can point several agents at the same scripts (one polishing dialogue, one fixing continuity, one tagging props) and watch each change appear live in the web app. For a job across thousands of scripts, a pass hands every agent lines nobody else holds, and each line is done once.
With the server attached, an agent can do things like "summarise every night exterior in this shooting script", "tighten MARA's dialogue in Act II", or "translate every untranslated line into French", citing and editing the exact scene and element UUIDs it works on.
Walkthroughs: have a team of agents edit a library and translate a whole library with a pass.
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.