Skip to content
screenjson

Screenplays, retrievable.

ScreenJSON is purpose-built for AI pipelines. Every scene, element, character, and line of dialogue is a typed node with a stable UUID. Feed that into a vector store, embed it, retrieve against it, cite it — and never parse a PDF screenplay again.

ScreenJSON AI page

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 analysis block 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:

  1. Chunk. Default to scene-level chunks — every scene becomes one passage. For very long scenes, split at element boundaries and use overlap to keep context continuity.
  2. Embed and store. Embed each chunk's concatenated text. Store in the vector DB keyed by passage UUID, with document_id, scene_id, and element_id in the metadata.
  3. Retrieve. Top-k cosine similarity against the user query, filtered by document or catalogue as needed.
  4. Hydrate. For each hit, look up its scene and element UUIDs in the canonical document. Pull the heading, cast, and the exact element text.
  5. 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.

7. Next steps