LegalSplit

Discovery Packet Splitter

Splits party information, case details, and request or response content from a multi-document civil discovery packet. Handles requests for admission, form and special interrogatories, requests for production, their corresponding responses, and a remedies reference chart.

Ship it with Extend

Live pipeline

a real document, processed end to end · view only
Source documentdiscovery packet legal.pdf

Step-by-step

A civil discovery packet is a compiled collection of written discovery documents exchanged between litigating parties in state civil proceedings, containing requests for admissions, form interrogatories, special interrogatories, requests for production of documents, and verified responses, along with case identification, party information, and statutory guidelines. This template takes in Civil Discovery Packet and outputs markdown (.md) files for each individual discovery document within the packet preserving original formatting and content, and JSON (.json) with extracted structured fields including case details, party information, document type, requests, and response requirements per the extraction schema by using Extend's Split primitives.

Input
Civil Discovery Packet
Compatible document types (full list)
.pdf.docx.xlsx.png.jpg.jpeg.tiff.tif.svg.heic.heif.bmp.gif.webp.psd.xls.xltm.xltx.ods.doc.wpd.dotx.odt.pptx.ppt.ppm.csv.txt.html.xml.rtf.lis.md.eml.pcx
Step 1

Split

Breaks a single file bundling multiple documents into separate, typed sub-documents.

InputBundled multi-document file, plus the expected document-type classifications
Config
splitClassifications8 document types definedchanged
splitRulesno custom rules
advancedOptions.pageOverlapEnabledfalse
baseProcessor"splitting_performance"
OutputOne segment per detected sub-document — type, page range, and an independent file ID for downstream processing

You can learn more about Split configuration in Extend's Split documentation.

Example code

{
  "name": "Discovery Packet Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              },
              "signatureDetectionEnabled": true
            },
            "tables": {
              "agentic": {
                "enabled": true
              },
              "tableHeaderContinuationEnabled": true
            },
            "figures": {
              "enabled": true
            }
          },
          "chunkingStrategy": {
            "type": "page",
            "options": {}
          }
        }
      },
      "next": [
        {
          "step": "split2"
        }
      ]
    },
    {
      "name": "split2",
      "type": "SPLIT",
      "config": {
        "splitterConfig": {
          "splitClassifications": [
            {
              "id": "splitter_classification1",
              "type": "other",
              "description": "Use the `other` document type when the document can not clearly be classified into one of the described classifications."
            },
            {
              "id": "subdocument_type_hL_",
              "type": "response to request for admission",
              "description": "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."
            },
            {
              "id": "subdocument_type_lIz",
              "type": "form interrogatories - general",
              "description": "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."
            },
            {
              "id": "subdocument_type_UMs",
              "type": "form interrogatories - limited civil",
              "description": "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."
            },
            {
              "id": "subdocument_type_H6Q",
              "type": "response to form interrogatories",
              "description": "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."
            },
            {
              "id": "subdocument_type_rgQ",
              "type": "request for production",
              "description": "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced."
            },
            {
              "id": "subdocument_type_Oh3",
              "type": "response to request for production",
              "description": "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification."
            },
            {
              "id": "subdocument_type_D8q",
              "type": "discovery remedies reference",
              "description": "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases."
            }
          ],
          "baseProcessor": "splitting_performance",
          "advancedOptions": {
            "pageOverlapEnabled": false
          }
        }
      }
    }
  ]
}
# Discovery Packet Processing — Extend AI Skill

## What this pipeline does

Ingests a civil litigation discovery packet (a bundled PDF containing requests for admission, interrogatories, requests for production, and verified responses), parses it into markdown with signature detection and agentic table handling, then splits it into individual discovery documents by type (Request for Admission, Response to Request for Admission, Form Interrogatories—General, Form Interrogatories—Limited Civil, Response to Form Interrogatories, Request for Production, Response to Request for Production, and Discovery Remedies Reference). The output is a set of typed sub-documents ready for downstream extraction, review workflows, or case management systems.

## When to use this

- **Legal intake & case file organization**: Automatically separate bundled discovery packets into distinct documents for filing in case management systems (Relativity, Logikcull, NetDocument, etc.)
- **Discovery compliance workflows**: Identify response documents vs. request documents to route them to different review queues or attorneys
- **Multi-party litigation**: Process discovery packets from multiple parties and split them to match opposing counsel's document structures
- **Timeline & obligation tracking**: Isolate form interrogatory responses (DISC-001/DISC-004) and request for production responses to cross-reference against deadline calendars
- **Remedies research & motion practice**: Separate the discovery remedies reference chart for quick lookup during meet-and-confer or motion drafting

## Processor pipeline

### Step 1: Parse (agentic_ocr mode, signature detection enabled)
- **Purpose**: Convert the bundled PDF into searchable, structured markdown with bounding boxes and signature locations.
- **Key config**: 
  - `agentic: { enabled: true }` on text blocks — handles varied formatting, bold/italic emphasis, numbered lists, and legal boilerplate
  - `signatureDetectionEnabled: true` — identifies signature blocks (used to distinguish verified responses from unsigned requests)
  - `tableHeaderContinuationEnabled: true` — tables spanning pages (common in multi-page interrogatory responses) are stitched together
  - `figures: { enabled: true }` — captures diagrams/exhibits (rare in discovery but preserved if present)
  - `chunkingStrategy: "page"` — one chunk per page (fine-grained, allows page-level split confidence)
- **Why**: Discovery packets are highly structured legal documents with consistent formatting. Agentic OCR handles the occasional scanned page or handwritten annotation. Signature detection is critical: verified responses have signature blocks; requests do not.

### Step 2: Split (splitting_performance mode, 8 classifications)
- **Purpose**: Segment the parsed document into individual discovery forms/responses, each labeled by type.
- **Key config**:
  - `splitClassifications`: 8 types covering requests, responses, and reference material (see Schema section below)
  - `baseProcessor: "splitting_performance"` — optimized for legal documents; balances accuracy and speed
  - `pageOverlapEnabled: false` — no overlap between splits (discovery documents are sequential, not overlapping)
- **Why**: California discovery rules require specific document types to be identified (DISC-001 vs. DISC-004, requests vs. responses). The split step assigns each document its canonical type, enabling downstream workflows to apply type-specific extraction or review logic. Performance mode is chosen because discovery packets are well-formatted and don't require the highest-latency agentic scoring.

---

## TypeScript implementation



---

## CLI equivalent

```bash
# Set your API key
export EXTEND_API_KEY="sk_..."

# Step 1: Parse the discovery packet with agentic OCR and signature detection
extend parse discovery_packet.pdf \
  --config '{
    "blockOptions": {
      "text": {
        "agentic": { "enabled": true },
        "signatureDetectionEnabled": true
      },
      "tables": {
        "agentic": { "enabled": true },
        "tableHeaderContinuationEnabled": true
      },
      "figures": { "enabled": true }
    },
    "chunkingStrategy": { "type": "page" }
  }'

# Step 2: Split by discovery document type
# First, save the split classifications to a file:
cat > split_types.json << 'EOF'
[
  {
    "id": "splitter_classification1",
    "type": "other",
    "description": "Use the `other` document type when the document can not clearly be classified into one of the described classifications."
  },
  {
    "id": "subdocument_type_hL_",
    "type": "response to request for admission",
    "description": "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."
  },
  {
    "id": "subdocument_type_lIz",
    "type": "form interrogatories - general",
    "description": "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."
  },
  {
    "id": "subdocument_type_UMs",
    "type": "form interrogatories - limited civil",
    "description": "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."
  },
  {
    "id": "subdocument_type_H6Q",
    "type": "response to form interrogatories",
    "description": "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."
  },
  {
    "id": "subdocument_type_rgQ",
    "type": "request for production",
    "description": "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party design
import { ExtendClient } from "extend-ai";
import fs from "fs";

const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

/**
 * Process a California discovery packet: parse it into markdown, then split
 * into individual discovery documents by type (Request for Admission, Response,
 * Form Interrogatories, etc.).
 */
async function processDiscoveryPacket(filePath: string) {
  // Convert local file to data URL (base64) for SDK consumption.
  const fileBuffer = fs.readFileSync(filePath);
  const dataUrl = `data:application/octet-stream;base64,${fileBuffer.toString("base64")}`;

  console.log(`📋 Processing discovery packet: ${filePath}`);

  // Step 1: Parse the bundled PDF into markdown with signature detection and agentic tables.
  console.log("\n🔍 Step 1: Parsing with agentic OCR, signature detection, and table continuation...");
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      parseConfig: {
        blockOptions: {
          text: {
            agentic: {
              enabled: true,
            },
            signatureDetectionEnabled: true,
          },
          tables: {
            agentic: {
              enabled: true,
            },
            tableHeaderContinuationEnabled: true,
          },
          figures: {
            enabled: true,
          },
        },
        chunkingStrategy: {
          type: "page",
          options: {},
        },
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    throw new Error(
      `Parse run failed with status ${parseRun.status}. ` +
      `${parseRun.error?.message || "No error details available."}`
    );
  }

  console.log(`✓ Parsed ${parseRun.output.chunks.length} pages into markdown.`);

  // Step 2: Split the parsed document into individual discovery forms/responses.
  console.log("\n✂️ Step 2: Splitting by discovery document type...");
  const splitRun = await client.workflowRuns.createAndPoll({
    file: { url: dataUrl },
    workflow: {
      // Use the workflow ID from your Extend account. If you don't have one saved,
      // create a split run directly with the splitRuns API (shown below as alternative).
      id: "workflow_discovery_packet",
    },
  });

  // If workflow is not available, use direct split API (alternative approach):
  // const splitRun = await client.splitRuns.createAndPoll({
  //   file: { url: dataUrl },
  //   config: {
  //     splitClassifications: [
  //       {
  //         id: "splitter_classification1",
  //         type: "other",
  //         description: "Use the `other` document type when the document can not clearly be classified into one of the described classifications."
  //       },
  //       {
  //         id: "subdocument_type_hL_",
  //         type: "response to request for admission",
  //         description: "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."
  //       },
  //       {
  //         id: "subdocument_type_lIz",
  //         type: "form interrogatories - general",
  //         description: "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."
  //       },
  //       {
  //         id: "subdocument_type_UMs",
  //         type: "form interrogatories - limited civil",
  //         description: "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."
  //       },
  //       {
  //         id: "subdocument_type_H6Q",
  //         type: "response to form interrogatories",
  //         description: "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."
  //       },
  //       {
  //         id: "subdocument_type_rgQ",
  //         type: "request for production",
  //         description: "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced."
  //       },
  //       {
  //         id: "subdocument_type_Oh3",
  //         type: "response to request for production",
  //         description: "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification."
  //       },
  //       {
  //         id: "subdocument_type_D8q",
  //         type: "discovery remedies reference",
  //         description: "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases."
  //       }
  //     ]
  //   }
  // });

  if (splitRun.status !== "PROCESSED") {
    throw new Error(
      `Split run failed with status ${splitRun.status}. ` +
      `${splitRun.error?.message || "No error details available."}`
    );
  }

  // Extract split results from the workflow run.
  const splitStepRun = splitRun.stepRuns?.find((sr) => sr.step.type === "SPLIT");
  if (!splitStepRun || !splitStepRun.result?.output?.splits) {
    throw new Error("Split step did not produce output splits.");
  }

  const splits = splitStepRun.result.output.splits;

  console.log(`✓ Split into ${splits.length} documents:\n`);

  // Summarize the splits: document type, page range, file ID.
  const typeGroups: Record<string, number> = {};
  for (const split of splits) {
    typeGroups[split.type] = (typeGroups[split.type] || 0) + 1;
    const pageRange = split.endPage
      ? `pages ${split.startPage}–${split.endPage}`
      : `page ${split.startPage}`;
    console.log(
      `  • [${split.type}] ${pageRange} → File ID: ${split.fileId}`
    );
  }

  console.log("\n📊 Summary by document type:");
  for (const [type, count] of Object.entries(typeGroups)) {
    console.log(`  • ${type}: ${count} document(s)`);
  }

  // Return structured output for downstream processing.
  return {
    parseStatus: parseRun.status,
    parsedChunks: parseRun.output.chunks.length,
    splits: splits.map((s) => ({
      fileId: s.fileId,
      type: s.type,
      startPage: s.startPage,
      endPage: s.endPage ?? null,
    })),
    summary: typeGroups,
  };
}

// Run the pipeline and log results.
(async () => {
  try {
    // Replace with your test file path.
    const testFilePath = process.argv[2] || "./discovery_packet.pdf";
    const result = await processDiscoveryPacket(testFilePath);
    console.log("\n✅ Pipeline completed successfully.");
    console.log(JSON.stringify(result, null, 2));
  } catch (error) {
    console.error("❌ Pipeline failed:", error);
    process.exit(1);
  }
})();
import os
import base64
from extend_ai import Extend

client = Extend(token=os.environ["EXTEND_API_KEY"])


def process_discovery_packet(file_path: str) -> dict:
    """
    Process a California discovery packet: parse it into markdown, then split
    into individual discovery documents by type (Request for Admission, Response,
    Form Interrogatories, etc.).
    """
    # Convert local file to data URL (base64) for SDK consumption.
    with open(file_path, "rb") as f:
        file_buffer = f.read()
    data_url = f"data:application/octet-stream;base64,{base64.b64encode(file_buffer).decode('utf-8')}"

    print(f"📋 Processing discovery packet: {file_path}")

    # Step 1: Parse the bundled PDF into markdown with signature detection and agentic tables.
    print("\n🔍 Step 1: Parsing with agentic OCR, signature detection, and table continuation...")
    parse_run = client.parse_runs.create_and_poll(
        file={"url": data_url},
        config={
            "parseConfig": {
                "blockOptions": {
                    "text": {
                        "agentic": {
                            "enabled": True,
                        },
                        "signatureDetectionEnabled": True,
                    },
                    "tables": {
                        "agentic": {
                            "enabled": True,
                        },
                        "tableHeaderContinuationEnabled": True,
                    },
                    "figures": {
                        "enabled": True,
                    },
                },
                "chunkingStrategy": {
                    "type": "page",
                    "options": {},
                },
            },
        },
    )

    if parse_run.status != "PROCESSED":
        error_msg = parse_run.error.message if parse_run.error else "No error details available."
        raise Exception(f"Parse run failed with status {parse_run.status}. {error_msg}")

    print(f"✓ Parsed {len(parse_run.output.chunks)} pages into markdown.")

    # Step 2: Split the parsed document into individual discovery forms/responses.
    print("\n✂️ Step 2: Splitting by discovery document type...")
    split_run = client.workflow_runs.create_and_poll(
        file={"url": data_url},
        workflow={
            "id": "workflow_discovery_packet",
        },
    )

    if split_run.status != "PROCESSED":
        error_msg = split_run.error.message if split_run.error else "No error details available."
        raise Exception(f"Split run failed with status {split_run.status}. {error_msg}")

    # Extract split results from the workflow run.
    split_step_run = None
    if split_run.step_runs:
        for sr in split_run.step_runs:
            if sr.step.type == "SPLIT":
                split_step_run = sr
                break

    if not split_step_run or not split_step_run.result or not split_step_run.result.output or not split_step_run.result.output.splits:
        raise Exception("Split step did not produce output splits.")

    splits = split_step_run.result.output.splits

    print(f"✓ Split into {len(splits)} documents:\n")

    # Summarize the splits: document type, page range, file ID.
    type_groups: dict[str, int] = {}
    for split in splits:
        type_groups[split.type] = type_groups.get(split.type, 0) + 1
        if split.end_page:
            page_range = f"pages {split.start_page}–{split.end_page}"
        else:
            page_range = f"page {split.start_page}"
        print(f"  • [{split.type}] {page_range} → File ID: {split.file_id}")

    print("\n📊 Summary by document type:")
    for doc_type, count in sorted(type_groups.items()):
        print(f"  • {doc_type}: {count} document(s)")

    # Return structured output for downstream processing.
    return {
        "parseStatus": parse_run.status,
        "parsedChunks": len(parse_run.output.chunks),
        "splits": [
            {
                "fileId": s.file_id,
                "type": s.type,
                "startPage": s.start_page,
                "endPage": s.end_page,
            }
            for s in splits
        ],
        "summary": type_groups,
    }


if __name__ == "__main__":
    import sys
    import json

    try:
        test_file_path = sys.argv[1] if len(sys.argv) > 1 else "./discovery_packet.pdf"
        result = process_discovery_packet(test_file_path)
        print("\n✅ Pipeline completed successfully.")
        print(json.dumps(result, indent=2))
    except Exception as error:
        print(f"❌ Pipeline failed: {error}")
        sys.exit(1)
// This code uses Extend's REST API directly because Extend has no official Java SDK yet.
// It calls https://api.extend.ai endpoints with java.net.http.HttpClient (no external dependencies).

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

public class DiscoveryPacketProcessor {

    private static final String API_BASE = "https://api.extend.ai";
    private static final String API_KEY = System.getenv("EXTEND_API_KEY");
    private final HttpClient httpClient = HttpClient.newHttpClient();

    public static void main(String[] args) throws Exception {
        try {
            String testFilePath = args.length > 0 ? args[0] : "./discovery_packet.pdf";
            DiscoveryPacketProcessor processor = new DiscoveryPacketProcessor();
            Map<String, Object> result = processor.processDiscoveryPacket(testFilePath);
            System.out.println("\n✅ Pipeline completed successfully.");
            System.out.println(prettyPrintJson(result));
        } catch (Exception error) {
            System.err.println("❌ Pipeline failed: " + error.getMessage());
            error.printStackTrace();
            System.exit(1);
        }
    }

    public Map<String, Object> processDiscoveryPacket(String filePath) throws Exception {
        // Convert local file to data URL (base64) for API consumption.
        byte[] fileBuffer = Files.readAllBytes(Paths.get(filePath));
        String base64 = Base64.getEncoder().encodeToString(fileBuffer);
        String dataUrl = "data:application/octet-stream;base64," + base64;

        System.out.println("📋 Processing discovery packet: " + filePath);

        // Step 1: Parse the bundled PDF into markdown with signature detection and agentic tables.
        System.out.println("\n🔍 Step 1: Parsing with agentic OCR, signature detection, and table continuation...");
        Map<String, Object> parseRun = createAndPollParseRun(dataUrl);

        String parseStatus = (String) parseRun.get("status");
        if (!"PROCESSED".equals(parseStatus)) {
            Map<String, Object> error = (Map<String, Object>) parseRun.get("error");
            String errorMsg = error != null ? (String) error.get("message") : "No error details available.";
            throw new Exception("Parse run failed with status " + parseStatus + ". " + errorMsg);
        }

        Map<String, Object> parseOutput = (Map<String, Object>) parseRun.get("output");
        List<?> chunks = (List<?>) parseOutput.get("chunks");
        System.out.println("✓ Parsed " + chunks.size() + " pages into markdown.");

        // Step 2: Split the parsed document into individual discovery forms/responses.
        System.out.println("\n✂️ Step 2: Splitting by discovery document type...");
        Map<String, Object> splitRun = createAndPollWorkflowRun(dataUrl);

        String splitStatus = (String) splitRun.get("status");
        if (!"PROCESSED".equals(splitStatus)) {
            Map<String, Object> error = (Map<String, Object>) splitRun.get("error");
            String errorMsg = error != null ? (String) error.get("message") : "No error details available.";
            throw new Exception("Split run failed with status " + splitStatus + ". " + errorMsg);
        }

        // Extract split results from the workflow run.
        List<?> stepRuns = (List<?>) splitRun.get("stepRuns");
        Map<String, Object> splitStepRun = null;
        if (stepRuns != null) {
            for (Object sr : stepRuns) {
                Map<String, Object> stepRunMap = (Map<String, Object>) sr;
                Map<String, Object> step = (Map<String, Object>) stepRunMap.get("step");
                if ("SPLIT".equals(step.get("type"))) {
                    splitStepRun = stepRunMap;
                    break;
                }
            }
        }

        if (splitStepRun == null) {
            throw new Exception("Split step did not produce output splits.");
        }

        Map<String, Object> result = (Map<String, Object>) splitStepRun.get("result");
        Map<String, Object> resultOutput = (Map<String, Object>) result.get("output");
        List<?> splits = (List<?>) resultOutput.get("splits");

        if (splits == null) {
            throw new Exception("Split step did not produce output splits.");
        }

        System.out.println("✓ Split into " + splits.size() + " documents:\n");

        // Summarize the splits: document type, page range, file ID.
        Map<String, Integer> typeGroups = new LinkedHashMap<>();
        for (Object splitObj : splits) {
            Map<String, Object> split = (Map<String, Object>) splitObj;
            String type = (String) split.get("type");
            typeGroups.put(type, typeGroups.getOrDefault(type, 0) + 1);

            Number startPageNum = (Number) split.get("startPage");
            Number endPageNum = (Number) split.get("endPage");
            String pageRange = endPageNum != null
                    ? "pages " + startPageNum + "–" + endPageNum
                    : "page " + startPageNum;
            System.out.println("  • [" + type + "] " + pageRange + " → File ID: " + split.get("fileId"));
        }

        System.out.println("\n📊 Summary by document type:");
        for (Map.Entry<String, Integer> entry : typeGroups.entrySet()) {
            System.out.println("  • " + entry.getKey() + ": " + entry.getValue() + " document(s)");
        }

        // Return structured output for downstream processing.
        List<Map<String, Object>> splitsOutput = new ArrayList<>();
        for (Object splitObj : splits) {
            Map<String, Object> split = (Map<String, Object>) splitObj;
            Map<String, Object> splitInfo = new LinkedHashMap<>();
            splitInfo.put("fileId", split.get("fileId"));
            splitInfo.put("type", split.get("type"));
            splitInfo.put("startPage", split.get("startPage"));
            splitInfo.put("endPage", split.get("endPage"));
            splitsOutput.add(splitInfo);
        }

        Map<String, Object> finalResult = new LinkedHashMap<>();
        finalResult.put("parseStatus", parseStatus);
        finalResult.put("parsedChunks", chunks.size());
        finalResult.put("splits", splitsOutput);
        finalResult.put("summary", typeGroups);

        return finalResult;
    }

    private Map<String, Object> createAndPollParseRun(String dataUrl) throws Exception {
        String requestBody = buildParseRunRequest(dataUrl);
        String runId = createParseRun(requestBody);
        return pollParseRun(runId);
    }

    private Map<String, Object> createAndPollWorkflowRun(String dataUrl) throws Exception {
        String requestBody = buildWorkflowRunRequest(dataUrl);
        String runId = createWorkflowRun(requestBody);
        return pollWorkflowRun(runId);
    }

    private String buildParseRunRequest(String dataUrl) {
        return "{"
                + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
                + "\"config\":{"
                + "\"parseConfig\":{"
                + "\"blockOptions\":{"
                + "\"text\":{"
                + "\"agentic\":{\"enabled\":true},"
                + "\"signatureDetectionEnabled\":true"
                + "},"
                + "\"tables\":{"
                + "\"agentic\":{\"enabled\":true},"
                + "\"tableHeaderContinuationEnabled\":true"
                + "},"
                + "\"figures\":{\"enabled\":true}"
                + "},"
                + "\"chunkingStrategy\":{\"type\":\"page\",\"options\":{}}"
                + "}"
                + "}"
                + "}";
    }

    private String buildWorkflowRunRequest(String dataUrl) {
        return "{"
                + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
                + "\"workflow\":{\"id\":\"workflow_discovery_packet\"}"
                + "}";
    }

    private String createParseRun(String requestBody) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(API_BASE + "/v1/parseRuns"))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200 && response.statusCode() != 201) {
            throw new Exception("Failed to create parse run: " + response.body());
        }

        Map<String, Object> responseBody = parseJsonResponse(response.body());
        return (String) responseBody.get("id");
    }

    private Map<String, Object> pollParseRun(String runId) throws Exception {
        while (true) {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(API_BASE + "/v1/parseRuns/" + runId))
                    .header("Authorization", "Bearer " + API_KEY)
                    .GET()
                    .build();

            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() != 200) {
                throw new Exception("Failed to poll parse run: " + response.body());
            }

            Map<String, Object> responseBody = parseJsonResponse(response.body());
            String status = (String) responseBody.get("status");

            if ("PROCESSED".equals(status) || "FAILED".equals(status)) {
                return responseBody;
            }

            Thread.sleep(2000);
        }
    }

    private String createWorkflowRun(String requestBody) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(API_BASE + "/v1/workflowRuns"))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200 && response.statusCode() != 201) {
            throw new Exception("Failed to create workflow run: " + response.body());
        }

        Map<String, Object> responseBody = parseJsonResponse(response.body());
        return (String) responseBody.get("id");
    }

    private Map<String, Object> pollWorkflowRun(String runId) throws Exception {
        while (true) {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(API_BASE + "/v1/workflowRuns/" + runId))
                    .header("Authorization", "Bearer " + API_KEY)
                    .GET()
                    .build();

            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() != 200) {
                throw new Exception("Failed to poll workflow run: " + response.body());
            }

            Map<String, Object> responseBody = parseJsonResponse(response.body());
            String status = (String) responseBody.get("status");

            if ("PROCESSED".equals(status) || "FAILED".equals(status)) {
                return responseBody;
            }

            Thread.sleep(2000);
        }
    }

    private Map<String, Object> parseJsonResponse(String json) {
        return parseJsonObject(json.trim());
    }

    private Map<String, Object> parseJsonObject(String json) {
        Map<String, Object> map = new LinkedHashMap<>();
        json = json.substring(1, json.length() - 1).trim();

        int depth = 0;
        int start = 0;
        String currentKey = null;

        for (int i = 0; i < json.length(); i++) {
            char c = json.charAt(i);

            if (c == '{' || c == '[') {
                depth++;
            } else if (c == '}' || c == ']') {
                depth--;
            } else if (c == ':' && depth == 0) {
                currentKey = json.substring(start, i).trim();
                currentKey = currentKey.replaceAll("^\"|\"$", "");
                start = i + 1;
            } else if ((c == ',' || i == json.length() - 1) && depth == 0) {
                if (i == json.length() - 1 && c != ',') {
                    i++;
                }
                String valueStr = json.substring(start, i).trim();
                if (!valueStr.isEmpty() && currentKey != null) {
                    map.put(currentKey, parseJsonValue(valueStr));
                }
                start = i + 1;
                currentKey = null;
            }
        }

        return map;
    }

    private List<?> parseJsonArray(String json) {
        List<Object> list = new ArrayList<>();
        json = json.substring(1, json.length() - 1).trim();

        if (json.isEmpty()) {
            return list;
        }

        int depth = 0;
        int start = 0;

        for (int i = 0; i < json.length(); i++) {
            char c = json.charAt(i);

            if (c == '{' || c == '[') {
                depth++;
            } else if (c == '}' || c == ']') {
                depth--;
            } else if (c == ',' && depth == 0) {
                String itemStr = json.substring(start, i).trim();
                list.add(parseJsonValue(itemStr));
                start = i + 1;
            } else if (i == json.length() - 1) {
                String itemStr = json.substring(start, i + 1).trim();
                list.add(parseJsonValue(itemStr));
            }
        }

        return list;
    }

    private Object parseJsonValue(String value) {
        value = value.trim();

        if (value.startsWith("{")) {
            return parseJsonObject(value);
        } else if (value.startsWith("[")) {
            return parseJsonArray(value);
        } else if (value.startsWith("\"")) {
            return value.substring(1, value.length() - 1);
        } else if ("true".equals(value)) {
            return true;
        } else if ("false".equals(value)) {
            return false;
        } else if ("null".equals(value)) {
            return null;
        } else {
            try {
                if (value.contains(".")) {
                    return Double.parseDouble(value);
                } else {
                    return Long.parseLong(value);
                }
            } catch (NumberFormatException e) {
                return value;
            }
        }
    }

    private String escapeJson(String str) {
        return str.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\r", "\\r")
                .replace("\t", "\\t");
    }

    private static String prettyPrintJson(Map<String, Object> map) {
        StringBuilder sb = new StringBuilder();
        sb.append("{\n");
        int count = 0;
        for (Map.Entry<String, Object> entry : map.entrySet()) {
            if (count > 0) sb.append(",\n");
            sb.append("  \"").append(entry.getKey()).append("\": ");
            sb.append(formatValue(entry.getValue(), 2));
            count++;
        }
        sb.append("\n}");
        return sb.toString();
    }

    private static String formatValue(Object value, int indent) {
        if (value == null) {
            return "null";
        } else if (value instanceof String) {
            return "\"" + value + "\"";
        } else if (value instanceof Map) {
            Map<String, Object> map = (Map<String, Object>) value;
            StringBuilder sb = new StringBuilder("{\n");
            int count = 0;
            for (Map.Entry<String, Object> entry : map.entrySet()) {
                if (count > 0) sb.append(",\n");
                sb.append(" ".repeat(indent + 2)).append("\"").append(entry.getKey()).append("\": ");
                sb.append(formatValue(entry.getValue(), indent + 2));
                count++;
            }
            sb.append("\n").append(" ".repeat(indent)).append("}");
            return sb.toString();
        } else if (value instanceof List) {
            List<?> list = (List<?>) value;
            StringBuilder sb = new StringBuilder("[\n");
            for (int i = 0; i < list.size(); i++) {
                if (i > 0) sb.append(",\n");
                sb.append(" ".repeat(indent + 2)).append(formatValue(list.get(i), indent + 2));
            }
            sb.append("\n").append(" ".repeat(indent)).append("]");
            return sb.toString();
        } else {
            return value.toString();
        }
    }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// It calls https://api.extend.ai endpoints with standard net/http and encoding/json.

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

const extendAPIBase = "https://api.extend.ai"

type ParseRunRequest struct {
	File   FileRef    `json:"file"`
	Config ParseConfig `json:"config"`
}

type FileRef struct {
	URL string `json:"url"`
}

type ParseConfig struct {
	ParseConfig struct {
		BlockOptions struct {
			Text struct {
				Agentic struct {
					Enabled bool `json:"enabled"`
				} `json:"agentic"`
				SignatureDetectionEnabled bool `json:"signatureDetectionEnabled"`
			} `json:"text"`
			Tables struct {
				Agentic struct {
					Enabled bool `json:"enabled"`
				} `json:"agentic"`
				TableHeaderContinuationEnabled bool `json:"tableHeaderContinuationEnabled"`
			} `json:"tables"`
			Figures struct {
				Enabled bool `json:"enabled"`
			} `json:"figures"`
		} `json:"blockOptions"`
		ChunkingStrategy struct {
			Type    string      `json:"type"`
			Options interface{} `json:"options"`
		} `json:"chunkingStrategy"`
	} `json:"parseConfig"`
}

type ParseRunResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Output struct {
		Chunks []interface{} `json:"chunks"`
	} `json:"output"`
	Error *struct {
		Message string `json:"message"`
	} `json:"error"`
}

type WorkflowRunRequest struct {
	File     FileRef `json:"file"`
	Workflow struct {
		ID string `json:"id"`
	} `json:"workflow"`
}

type WorkflowRunResponse struct {
	ID       string `json:"id"`
	Status   string `json:"status"`
	StepRuns []struct {
		Step struct {
			Type string `json:"type"`
		} `json:"step"`
		Result struct {
			Output struct {
				Splits []struct {
					FileID    string `json:"fileId"`
					Type      string `json:"type"`
					StartPage int    `json:"startPage"`
					EndPage   *int   `json:"endPage"`
				} `json:"splits"`
			} `json:"output"`
		} `json:"result"`
	} `json:"stepRuns"`
	Error *struct {
		Message string `json:"message"`
	} `json:"error"`
}

type ProcessResult struct {
	ParseStatus   string `json:"parseStatus"`
	ParsedChunks  int    `json:"parsedChunks"`
	Splits        []SplitInfo `json:"splits"`
	Summary       map[string]int `json:"summary"`
}

type SplitInfo struct {
	FileID    string `json:"fileId"`
	Type      string `json:"type"`
	StartPage int    `json:"startPage"`
	EndPage   *int   `json:"endPage"`
}

func makeRequest(method, path string, body interface{}, apiKey string, result interface{}) error {
	url := extendAPIBase + path
	var reqBody io.Reader
	if body != nil {
		jsonBody, err := json.Marshal(body)
		if err != nil {
			return err
		}
		reqBody = bytes.NewReader(jsonBody)
	}

	req, err := http.NewRequest(method, url, reqBody)
	if err != nil {
		return err
	}

	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 30 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}

	if resp.StatusCode >= 400 {
		return fmt.Errorf("API error: status %d, body: %s", resp.StatusCode, string(respBody))
	}

	if result != nil {
		if err := json.Unmarshal(respBody, result); err != nil {
			return err
		}
	}

	return nil
}

func pollParseRun(runID, apiKey string) (*ParseRunResponse, error) {
	for {
		var run ParseRunResponse
		if err := makeRequest("GET", "/v1/parseRuns/"+runID, nil, apiKey, &run); err != nil {
			return nil, err
		}

		if run.Status == "PROCESSED" || run.Status == "FAILED" {
			return &run, nil
		}

		time.Sleep(2 * time.Second)
	}
}

func pollWorkflowRun(runID, apiKey string) (*WorkflowRunResponse, error) {
	for {
		var run WorkflowRunResponse
		if err := makeRequest("GET", "/v1/workflowRuns/"+runID, nil, apiKey, &run); err != nil {
			return nil, err
		}

		if run.Status == "PROCESSED" || run.Status == "FAILED" {
			return &run, nil
		}

		time.Sleep(2 * time.Second)
	}
}

func processDiscoveryPacket(filePath string, apiKey string) (*ProcessResult, error) {
	fileBuffer, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	dataURL := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString(fileBuffer)

	fmt.Printf("📋 Processing discovery packet: %s\n", filePath)

	// Step 1: Parse the bundled PDF into markdown
	fmt.Println("\n🔍 Step 1: Parsing with agentic OCR, signature detection, and table continuation...")

	parseReq := ParseRunRequest{
		File: FileRef{URL: dataURL},
	}
	parseReq.Config.ParseConfig.BlockOptions.Text.Agentic.Enabled = true
	parseReq.Config.ParseConfig.BlockOptions.Text.SignatureDetectionEnabled = true
	parseReq.Config.ParseConfig.BlockOptions.Tables.Agentic.Enabled = true
	parseReq.Config.ParseConfig.BlockOptions.Tables.TableHeaderContinuationEnabled = true
	parseReq.Config.ParseConfig.BlockOptions.Figures.Enabled = true
	parseReq.Config.ParseConfig.ChunkingStrategy.Type = "page"
	parseReq.Config.ParseConfig.ChunkingStrategy.Options = map[string]interface{}{}

	var parseCreateResp struct {
		ID string `json:"id"`
	}
	if err := makeRequest("POST", "/v1/parseRuns", parseReq, apiKey, &parseCreateResp); err != nil {
		return nil, err
	}

	parseRun, err := pollParseRun(parseCreateResp.ID, apiKey)
	if err != nil {
		return nil, err
	}

	if parseRun.Status != "PROCESSED" {
		errMsg := "No error details available."
		if parseRun.Error != nil {
			errMsg = parseRun.Error.Message
		}
		return nil, fmt.Errorf("parse run failed with status %s. %s", parseRun.Status, errMsg)
	}

	fmt.Printf("✓ Parsed %d pages into markdown.\n", len(parseRun.Output.Chunks))

	// Step 2: Split the parsed document into individual discovery forms/responses
	fmt.Println("\n✂️ Step 2: Splitting by discovery document type...")

	workflowReq := WorkflowRunRequest{
		File: FileRef{URL: dataURL},
	}
	workflowReq.Workflow.ID = "workflow_discovery_packet"

	var workflowCreateResp struct {
		ID string `json:"id"`
	}
	if err := makeRequest("POST", "/v1/workflowRuns", workflowReq, apiKey, &workflowCreateResp); err != nil {
		return nil, err
	}

	splitRun, err := pollWorkflowRun(workflowCreateResp.ID, apiKey)
	if err != nil {
		return nil, err
	}

	if splitRun.Status != "PROCESSED" {
		errMsg := "No error details available."
		if splitRun.Error != nil {
			errMsg = splitRun.Error.Message
		}
		return nil, fmt.Errorf("split run failed with status %s. %s", splitRun.Status, errMsg)
	}

	// Extract split results from the workflow run
	var splits []struct {
		FileID    string `json:"fileId"`
		Type      string `json:"type"`
		StartPage int    `json:"startPage"`
		EndPage   *int   `json:"endPage"`
	}

	if len(splitRun.StepRuns) == 0 {
		return nil, fmt.Errorf("split step did not produce output splits")
	}

	for _, sr := range splitRun.StepRuns {
		if sr.Step.Type == "SPLIT" {
			splits = sr.Result.Output.Splits
			break
		}
	}

	if len(splits) == 0 {
		return nil, fmt.Errorf("split step did not produce output splits")
	}

	fmt.Printf("✓ Split into %d documents:\n\n", len(splits))

	// Summarize the splits
	typeGroups := make(map[string]int)
	for _, split := range splits {
		typeGroups[split.Type]++
		pageRange := fmt.Sprintf("page %d", split.StartPage)
		if split.EndPage != nil {
			pageRange = fmt.Sprintf("pages %d–%d", split.StartPage, *split.EndPage)
		}
		fmt.Printf("  • [%s] %s → File ID: %s\n", split.Type, pageRange, split.FileID)
	}

	fmt.Println("\n📊 Summary by document type:")
	for docType, count := range typeGroups {
		fmt.Printf("  • %s: %d document(s)\n", docType, count)
	}

	// Return structured output
	splitInfos := make([]SplitInfo, len(splits))
	for i, s := range splits {
		splitInfos[i] = SplitInfo{
			FileID:    s.FileID,
			Type:      s.Type,
			StartPage: s.StartPage,
			EndPage:   s.EndPage,
		}
	}

	return &ProcessResult{
		ParseStatus:  parseRun.Status,
		ParsedChunks: len(parseRun.Output.Chunks),
		Splits:       splitInfos,
		Summary:      typeGroups,
	}, nil
}

func main() {
	apiKey := os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		fmt.Fprintf(os.Stderr, "❌ EXTEND_API_KEY environment variable not set\n")
		os.Exit(1)
	}

	testFilePath := "./discovery_packet.pdf"
	if len(os.Args) > 1 {
		testFilePath = os.Args[1]
	}

	// Ensure file exists
	if _, err := os.Stat(testFilePath); err != nil {
		fmt.Fprintf(os.Stderr, "❌ File not found: %s\n", testFilePath)
		os.Exit(1)
	}

	result, err := processDiscoveryPacket(testFilePath, apiKey)
	if err != nil {
		fmt.Fprintf(os.Stderr, "❌ Pipeline failed: %v\n", err)
		os.Exit(1)
	}

	fmt.Println("\n✅ Pipeline completed successfully.")
	resultJSON, _ := json.MarshalIndent(result, "", "  ")
	fmt.Println(string(resultJSON))
}
// Deploy the "Discovery Packet" pipeline to YOUR Extend account.
//
// The workflow below is fully self-contained — every EXTRACT/CLASSIFY/SPLIT
// step carries its extractor/classifier/splitter config INLINE, so this is a
// single API call. No processors to create or wire up beforehand.
// Idempotent: the created workflow id is cached in .extend/discovery-packet-splitter.json,
// so re-running updates the existing workflow instead of duplicating it.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   npx tsx provision.ts
//
// Generated by doc1 (template: discovery-packet-splitter).

import fs from "node:fs";
import path from "node:path";

const API = "https://api.extend.ai";
const VERSION = "2026-02-09";
const API_KEY = process.env.EXTEND_API_KEY;
if (!API_KEY) { console.error("Set EXTEND_API_KEY first."); process.exit(1); }

const STATE_DIR = path.join(process.cwd(), ".extend");
const STATE_FILE = path.join(STATE_DIR, "discovery-packet-splitter.json");

type State = { workflowId?: string };
const state: State = fs.existsSync(STATE_FILE)
  ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
  : {};
function saveState() {
  fs.mkdirSync(STATE_DIR, { recursive: true });
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

async function api(method: string, pathName: string, body?: unknown) {
  const res = await fetch(API + pathName, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "x-extend-api-version": VERSION,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${pathName} failed (${res.status}): ${JSON.stringify(data).slice(0, 300)}`);
  return data;
}

// ── Workflow definition — extractor/classifier/splitter configs inline ──────
const WORKFLOW = {
  "name": "Discovery Packet Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              },
              "signatureDetectionEnabled": true
            },
            "tables": {
              "agentic": {
                "enabled": true
              },
              "tableHeaderContinuationEnabled": true
            },
            "figures": {
              "enabled": true
            }
          },
          "chunkingStrategy": {
            "type": "page",
            "options": {}
          }
        }
      },
      "next": [
        {
          "step": "split2"
        }
      ]
    },
    {
      "name": "split2",
      "type": "SPLIT",
      "config": {
        "splitterConfig": {
          "splitClassifications": [
            {
              "id": "splitter_classification1",
              "type": "other",
              "description": "Use the `other` document type when the document can not clearly be classified into one of the described classifications."
            },
            {
              "id": "subdocument_type_hL_",
              "type": "response to request for admission",
              "description": "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."
            },
            {
              "id": "subdocument_type_lIz",
              "type": "form interrogatories - general",
              "description": "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."
            },
            {
              "id": "subdocument_type_UMs",
              "type": "form interrogatories - limited civil",
              "description": "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."
            },
            {
              "id": "subdocument_type_H6Q",
              "type": "response to form interrogatories",
              "description": "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."
            },
            {
              "id": "subdocument_type_rgQ",
              "type": "request for production",
              "description": "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced."
            },
            {
              "id": "subdocument_type_Oh3",
              "type": "response to request for production",
              "description": "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification."
            },
            {
              "id": "subdocument_type_D8q",
              "type": "discovery remedies reference",
              "description": "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases."
            }
          ],
          "baseProcessor": "splitting_performance",
          "advancedOptions": {
            "pageOverlapEnabled": false
          }
        }
      }
    }
  ]
};

async function main() {
  console.log(`Deploying "${WORKFLOW.name}"…`);

  if (state.workflowId) {
    console.log(`✓ workflow already provisioned (${state.workflowId}) — updating steps`);
    await api("POST", `/workflows/${state.workflowId}`, { steps: WORKFLOW.steps });
  } else {
    // Reuse an existing workflow with the same name if one exists (e.g. a
    // previous run's state file was lost) instead of creating a duplicate.
    try {
      const list = await api("GET", `/workflows?name=${encodeURIComponent(WORKFLOW.name)}`);
      const items = (list.data ?? list.items ?? []) as Array<{ name?: string; id?: string }>;
      const existing = items.find((x) => x.name === WORKFLOW.name);
      if (existing?.id) {
        state.workflowId = existing.id; saveState();
        console.log(`✓ workflow "${WORKFLOW.name}" found in your account (${existing.id}) — updating steps`);
        await api("POST", `/workflows/${existing.id}`, { steps: WORKFLOW.steps });
      }
    } catch { /* lookup is best-effort; fall through to create */ }

    if (!state.workflowId) {
      const created = await api("POST", "/workflows", WORKFLOW);
      const wfId = created.id ?? created.workflow?.id;
      if (!wfId) throw new Error("Could not read created workflow id from response");
      state.workflowId = wfId; saveState();
      console.log(`+ created workflow (${wfId})`);
    }
  }

  // Deploy the current draft as a new version so the workflow is runnable —
  // best-effort: some accounts/plans may not require this explicit step.
  await api("POST", `/workflows/${state.workflowId}/versions`, {}).catch(() => {});

  console.log("\nDone. Run documents through it with:");
  console.log(`  POST ${API}/workflow_runs  { workflow: { id: "${state.workflowId}" }, file: { url: "https://…" } }`);
  console.log("Or open the workflow in the Extend dashboard to review and deploy it.");
}

main().catch((e) => { console.error(e.message ?? e); process.exit(1); });
import os
import json
import sys
from pathlib import Path
from extend_ai import Extend

API_KEY = os.environ.get("EXTEND_API_KEY")
if not API_KEY:
    print("Set EXTEND_API_KEY first.", file=sys.stderr)
    sys.exit(1)

STATE_DIR = Path.cwd() / ".extend"
STATE_FILE = STATE_DIR / "discovery-packet-splitter.json"

state = {}
if STATE_FILE.exists():
    state = json.loads(STATE_FILE.read_text())

def save_state():
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2))

WORKFLOW = {
    "name": "Discovery Packet Processing Pipeline",
    "steps": [
        {
            "name": "startTrigger1",
            "type": "TRIGGER",
            "next": [
                {
                    "step": "parse1"
                }
            ]
        },
        {
            "name": "parse1",
            "type": "PARSE",
            "config": {
                "parseConfig": {
                    "blockOptions": {
                        "text": {
                            "agentic": {
                                "enabled": True
                            },
                            "signatureDetectionEnabled": True
                        },
                        "tables": {
                            "agentic": {
                                "enabled": True
                            },
                            "tableHeaderContinuationEnabled": True
                        },
                        "figures": {
                            "enabled": True
                        }
                    },
                    "chunkingStrategy": {
                        "type": "page",
                        "options": {}
                    }
                }
            },
            "next": [
                {
                    "step": "split2"
                }
            ]
        },
        {
            "name": "split2",
            "type": "SPLIT",
            "config": {
                "splitterConfig": {
                    "splitClassifications": [
                        {
                            "id": "splitter_classification1",
                            "type": "other",
                            "description": "Use the `other` document type when the document can not clearly be classified into one of the described classifications."
                        },
                        {
                            "id": "subdocument_type_hL_",
                            "type": "response to request for admission",
                            "description": "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."
                        },
                        {
                            "id": "subdocument_type_lIz",
                            "type": "form interrogatories - general",
                            "description": "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."
                        },
                        {
                            "id": "subdocument_type_UMs",
                            "type": "form interrogatories - limited civil",
                            "description": "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."
                        },
                        {
                            "id": "subdocument_type_H6Q",
                            "type": "response to form interrogatories",
                            "description": "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."
                        },
                        {
                            "id": "subdocument_type_rgQ",
                            "type": "request for production",
                            "description": "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced."
                        },
                        {
                            "id": "subdocument_type_Oh3",
                            "type": "response to request for production",
                            "description": "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification."
                        },
                        {
                            "id": "subdocument_type_D8q",
                            "type": "discovery remedies reference",
                            "description": "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases."
                        }
                    ],
                    "baseProcessor": "splitting_performance",
                    "advancedOptions": {
                        "pageOverlapEnabled": False
                    }
                }
            }
        }
    ]
}

def main():
    client = Extend(token=API_KEY)
    
    print(f"Deploying \"{WORKFLOW['name']}\"…")
    
    if state.get("workflowId"):
        workflow_id = state["workflowId"]
        print(f"✓ workflow already provisioned ({workflow_id}) — updating steps")
        client.workflows.update(workflow_id, steps=WORKFLOW["steps"])
    else:
        # Try to find existing workflow with same name
        existing_id = None
        try:
            workflows_list = client.workflows.list(name=WORKFLOW["name"])
            items = workflows_list.data if hasattr(workflows_list, "data") else (workflows_list.items if hasattr(workflows_list, "items") else [])
            for item in items:
                if item.name == WORKFLOW["name"]:
                    existing_id = item.id
                    break
        except Exception:
            pass
        
        if existing_id:
            state["workflowId"] = existing_id
            save_state()
            print(f"✓ workflow \"{WORKFLOW['name']}\" found in your account ({existing_id}) — updating steps")
            client.workflows.update(existing_id, steps=WORKFLOW["steps"])
        else:
            created = client.workflows.create(**WORKFLOW)
            workflow_id = created.id
            if not workflow_id:
                raise Exception("Could not read created workflow id from response")
            state["workflowId"] = workflow_id
            save_state()
            print(f"+ created workflow ({workflow_id})")
    
    # Deploy the current draft as a new version
    try:
        client.workflows.create_version(state["workflowId"])
    except Exception:
        pass
    
    print("\nDone. Run documents through it with:")
    print(f"  POST https://api.extend.ai/workflow_runs  {{ workflow: {{ id: \"{state['workflowId']}\" }}, file: {{ url: \"https://…\" }} }}")
    print("Or open the workflow in the Extend dashboard to review and deploy it.")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)
// This code uses Extend's REST API directly because Extend has no official Java SDK yet.
// It calls https://api.extend.ai endpoints with Bearer token authentication.

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ProvisionDiscoveryPacket {
  private static final String API = "https://api.extend.ai";
  private static final String VERSION = "2026-02-09";
  private static final String API_KEY = System.getenv("EXTEND_API_KEY");
  private static final Path STATE_DIR = Paths.get(System.getProperty("user.dir"), ".extend");
  private static final Path STATE_FILE = STATE_DIR.resolve("discovery-packet-splitter.json");
  private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();

  static class State {
    String workflowId;
  }

  private static State state = new State();

  public static void main(String[] args) {
    try {
      if (API_KEY == null || API_KEY.isEmpty()) {
        System.err.println("Set EXTEND_API_KEY first.");
        System.exit(1);
      }

      loadState();

      Map<String, Object> workflow = buildWorkflow();
      String workflowName = (String) workflow.get("name");

      System.out.println("Deploying \"" + workflowName + "\"…");

      if (state.workflowId != null && !state.workflowId.isEmpty()) {
        System.out.println("✓ workflow already provisioned (" + state.workflowId + ") — updating steps");
        Map<String, Object> updateBody = new HashMap<>();
        updateBody.put("steps", workflow.get("steps"));
        api("POST", "/workflows/" + state.workflowId, updateBody);
      } else {
        try {
          String encodedName = URLEncoder.encode(workflowName, StandardCharsets.UTF_8);
          Map<String, Object> listResponse = api("GET", "/workflows?name=" + encodedName, null);
          List<Map<String, Object>> items = (List<Map<String, Object>>) listResponse.getOrDefault("data",
              listResponse.getOrDefault("items", new ArrayList<>()));

          Map<String, Object> existing = null;
          for (Map<String, Object> item : items) {
            if (workflowName.equals(item.get("name"))) {
              existing = item;
              break;
            }
          }

          if (existing != null && existing.get("id") != null) {
            state.workflowId = (String) existing.get("id");
            saveState();
            System.out.println("✓ workflow \"" + workflowName + "\" found in your account (" + state.workflowId
                + ") — updating steps");
            Map<String, Object> updateBody = new HashMap<>();
            updateBody.put("steps", workflow.get("steps"));
            api("POST", "/workflows/" + state.workflowId, updateBody);
          }
        } catch (Exception e) {
          // lookup is best-effort; fall through to create
        }

        if (state.workflowId == null || state.workflowId.isEmpty()) {
          Map<String, Object> created = api("POST", "/workflows", workflow);
          String wfId = (String) created.get("id");
          if (wfId == null) {
            Map<String, Object> workflowObj = (Map<String, Object>) created.get("workflow");
            if (workflowObj != null) {
              wfId = (String) workflowObj.get("id");
            }
          }
          if (wfId == null) {
            throw new RuntimeException("Could not read created workflow id from response");
          }
          state.workflowId = wfId;
          saveState();
          System.out.println("+ created workflow (" + wfId + ")");
        }
      }

      try {
        api("POST", "/workflows/" + state.workflowId + "/versions", new HashMap<>());
      } catch (Exception e) {
        // best-effort: some accounts/plans may not require this explicit step
      }

      System.out.println("\nDone. Run documents through it with:");
      System.out.println("  POST " + API + "/workflow_runs  { workflow: { id: \"" + state.workflowId
          + "\" }, file: { url: \"https://…\" } }");
      System.out.println("Or open the workflow in the Extend dashboard to review and deploy it.");

    } catch (Exception e) {
      System.err.println(e.getMessage() != null ? e.getMessage() : e.toString());
      System.exit(1);
    }
  }

  private static void loadState() throws IOException {
    if (Files.exists(STATE_FILE)) {
      String content = Files.readString(STATE_FILE);
      Map<String, Object> parsed = parseJson(content);
      if (parsed.containsKey("workflowId")) {
        state.workflowId = (String) parsed.get("workflowId");
      }
    }
  }

  private static void saveState() throws IOException {
    Files.createDirectories(STATE_DIR);
    Map<String, Object> stateMap = new HashMap<>();
    if (state.workflowId != null) {
      stateMap.put("workflowId", state.workflowId);
    }
    String json = toJson(stateMap);
    Files.writeString(STATE_FILE, json);
  }

  private static Map<String, Object> api(String method, String pathName, Map<String, Object> body)
      throws IOException, InterruptedException {
    String url = API + pathName;
    HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url))
        .header("Authorization", "Bearer " + API_KEY).header("x-extend-api-version", VERSION);

    if ("POST".equals(method)) {
      requestBuilder.method("POST", HttpRequest.BodyPublishers.ofString(toJson(body)));
      requestBuilder.header("Content-Type", "application/json");
    } else if ("GET".equals(method)) {
      requestBuilder.GET();
    }

    HttpRequest request = requestBuilder.build();
    HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

    Map<String, Object> data = new HashMap<>();
    if (!response.body().isEmpty()) {
      try {
        data = parseJson(response.body());
      } catch (Exception e) {
        // empty or invalid JSON
      }
    }

    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      String errorMsg = toJson(data);
      if (errorMsg.length() > 300) {
        errorMsg = errorMsg.substring(0, 300);
      }
      throw new RuntimeException(
          method + " " + pathName + " failed (" + response.statusCode() + "): " + errorMsg);
    }

    return data;
  }

  private static Map<String, Object> buildWorkflow() {
    Map<String, Object> workflow = new LinkedHashMap<>();
    workflow.put("name", "Discovery Packet Processing Pipeline");

    List<Map<String, Object>> steps = new ArrayList<>();

    // startTrigger1
    Map<String, Object> startTrigger = new LinkedHashMap<>();
    startTrigger.put("name", "startTrigger1");
    startTrigger.put("type", "TRIGGER");
    List<Map<String, Object>> startNext = new ArrayList<>();
    Map<String, Object> startNextItem = new LinkedHashMap<>();
    startNextItem.put("step", "parse1");
    startNext.add(startNextItem);
    startTrigger.put("next", startNext);
    steps.add(startTrigger);

    // parse1
    Map<String, Object> parse = new LinkedHashMap<>();
    parse.put("name", "parse1");
    parse.put("type", "PARSE");
    Map<String, Object> parseConfig = new LinkedHashMap<>();
    Map<String, Object> blockOptions = new LinkedHashMap<>();
    Map<String, Object> textBlock = new LinkedHashMap<>();
    Map<String, Object> textAgentic = new LinkedHashMap<>();
    textAgentic.put("enabled", true);
    textBlock.put("agentic", textAgentic);
    textBlock.put("signatureDetectionEnabled", true);
    blockOptions.put("text", textBlock);
    Map<String, Object> tablesBlock = new LinkedHashMap<>();
    Map<String, Object> tablesAgentic = new LinkedHashMap<>();
    tablesAgentic.put("enabled", true);
    tablesBlock.put("agentic", tablesAgentic);
    tablesBlock.put("tableHeaderContinuationEnabled", true);
    blockOptions.put("tables", tablesBlock);
    Map<String, Object> figuresBlock = new LinkedHashMap<>();
    figuresBlock.put("enabled", true);
    blockOptions.put("figures", figuresBlock);
    parseConfig.put("blockOptions", blockOptions);
    Map<String, Object> chunkingStrategy = new LinkedHashMap<>();
    chunkingStrategy.put("type", "page");
    chunkingStrategy.put("options", new LinkedHashMap<>());
    parseConfig.put("chunkingStrategy", chunkingStrategy);
    Map<String, Object> parseConfigWrapper = new LinkedHashMap<>();
    parseConfigWrapper.put("parseConfig", parseConfig);
    parse.put("config", parseConfigWrapper);
    List<Map<String, Object>> parseNext = new ArrayList<>();
    Map<String, Object> parseNextItem = new LinkedHashMap<>();
    parseNextItem.put("step", "split2");
    parseNext.add(parseNextItem);
    parse.put("next", parseNext);
    steps.add(parse);

    // split2
    Map<String, Object> split = new LinkedHashMap<>();
    split.put("name", "split2");
    split.put("type", "SPLIT");
    Map<String, Object> splitConfig = new LinkedHashMap<>();
    List<Map<String, Object>> splitClassifications = new ArrayList<>();

    splitClassifications.add(createClassification("splitter_classification1", "other",
        "Use the `other` document type when the document can not clearly be classified into one of the described classifications."));
    splitClassifications.add(createClassification("subdocument_type_hL_", "response to request for admission",
        "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block."));
    splitClassifications.add(createClassification("subdocument_type_lIz", "form interrogatories - general",
        "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms."));
    splitClassifications.add(createClassification("subdocument_type_UMs", "form interrogatories - limited civil",
        "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms."));
    splitClassifications.add(createClassification("subdocument_type_H6Q", "response to form interrogatories",
        "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block."));
    splitClassifications.add(createClassification("subdocument_type_rgQ", "request for production",
        "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced."));
    splitClassifications.add(createClassification("subdocument_type_Oh3", "response to request for production",
        "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification."));
    splitClassifications.add(createClassification("subdocument_type_D8q", "discovery remedies reference",
        "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases."));

    splitConfig.put("splitClassifications", splitClassifications);
    splitConfig.put("baseProcessor", "splitting_performance");
    Map<String, Object> advancedOptions = new LinkedHashMap<>();
    advancedOptions.put("pageOverlapEnabled", false);
    splitConfig.put("advancedOptions", advancedOptions);
    Map<String, Object> splitConfigWrapper = new LinkedHashMap<>();
    splitConfigWrapper.put("splitterConfig", splitConfig);
    split.put("config", splitConfigWrapper);
    steps.add(split);

    workflow.put("steps", steps);
    return workflow;
  }

  private static Map<String, Object> createClassification(String id, String type, String description) {
    Map<String, Object> classification = new LinkedHashMap<>();
    classification.put("id", id);
    classification.put("type", type);
    classification.put("description", description);
    return classification;
  }

  private static String toJson(Map<String, Object> map) {
    StringBuilder sb = new StringBuilder();
    sb.append("{");
    boolean first = true;
    for (Map.Entry<String, Object> entry : map.entrySet()) {
      if (!first)
        sb.append(",");
      first = false;
      sb.append("\"").append(escapeJson(entry.getKey())).append("\":");
      sb.append(valueToJson(entry.getValue()));
    }
    sb.append("}");
    return sb.toString();
  }

  private static String valueToJson(Object value) {
    if (value == null) {
      return "null";
    } else if (value instanceof String) {
      return "\"" + escapeJson((String) value) + "\"";
    } else if (value instanceof Boolean) {
      return value.toString();
    } else if (value instanceof Number) {
      return value.toString();
    } else if (value instanceof Map) {
      return toJson((Map<String, Object>) value);
    } else if (value instanceof List) {
      StringBuilder sb = new StringBuilder("[");
      List<?> list = (List<?>) value;
      for (int i = 0; i < list.size(); i++) {
        if (i > 0)
          sb.append(",");
        sb.append(valueToJson(list.get(i)));
      }
      sb.append("]");
      return sb.toString();
    }
    return "null";
  }

  private static String escapeJson(String s) {
    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r")
        .replace("\t", "\\t");
  }

  private static Map<String, Object> parseJson(String json) {
    json = json.trim();
    if (!json.startsWith("{")) {
      return new HashMap<>();
    }
    Map<String, Object> result = new LinkedHashMap<>();
    int depth = 0;
    StringBuilder currentKey = new StringBuilder();
    StringBuilder currentValue = new StringBuilder();
    boolean inString = false;
    boolean inKey = true;
    boolean escaped = false;

    for (int i = 1; i < json.length() - 1; i++) {
      char c = json.charAt(i);

      if (escaped) {
        if (inKey) {
          currentKey.append(c);
        } else {
          currentValue.append(c);
        }
        escaped = false;
        continue;
      }

      if (c == '\\' && inString) {
        escaped = true;
        if (inKey) {
          currentKey.append(c);
        } else {
          currentValue.append(c);
        }
        continue;
      }

      if (c == '"') {
        inString = !inString;
        if (inKey) {
          currentKey.append(c);
        } else {
          currentValue.append(c);
        }
        continue;
      }

      if (!inString) {
        if (c == ':' && inKey && depth == 0) {
          inKey = false;
          continue;
        }
        if (c == ',' && depth == 0) {
          String key = currentKey.toString().trim();
          if (key.startsWith("\"") && key.endsWith("\"")) {
            key = key.substring(1, key.length() - 1);
          }
          String value = currentValue.toString().trim();
          result.put(key, parseValue(value));
          currentKey = new StringBuilder();
          currentValue = new StringBuilder();
          inKey = true;
          continue;
        }
        if (c == '{' || c == '[') {
          depth++;
        } else if (c == '}' || c == ']') {
          depth--;
        }
      }

      if (inKey) {
        currentKey.append(c);
      } else {
        currentValue.append(c);
      }
    }

    if (currentKey.length() > 0) {
      String key = currentKey.toString().trim();
      if (key.startsWith("\"") && key.endsWith("\"")) {
        key = key.substring(1, key.length() - 1);
      }
      String value = currentValue.toString().trim();
      result.put(key, parseValue(value));
    }

    return result;
  }

  private static Object parseValue(String value) {
    value = value.trim();
    if (value.equals("null")) {
      return null;
    } else if (value.equals("true")) {
      return true;
    } else if (value.equals("false")) {
      return false;
    } else if (value.startsWith("\"") && value.endsWith("\"")) {
      return value.substring(1, value.length() - 1);
    } else if (value.startsWith("{") && value.endsWith("}")) {
      return parseJson(value);
    } else if (value.startsWith("[") && value.endsWith("]")) {
      List<Object> list = new ArrayList<>();
      int depth = 0;
      StringBuilder item = new StringBuilder();
      boolean inString = false;
      boolean escaped = false;

      for (int i = 1; i < value.length() - 1; i++) {
        char c = value.charAt(i);

        if (escaped) {
          item.append(c);
          escaped = false;
          continue;
        }

        if (c == '\\' && inString) {
          escaped = true;
          item.append(c);
          continue;
        }

        if (c == '"') {
          inString = !inString;
          item.append(c);
          continue;
        }

        if (!inString) {
          if (c == '{' || c == '[') {
            depth++;
          } else if (c == '}' || c == ']') {
            depth--;
          } else if (c == ',' && depth == 0) {
            list.add(parseValue(item.toString()));
            item = new StringBuilder();
            continue;
          }
        }

        item.append(c);
      }

      if (item.length() > 0) {
        list.add(parseValue(item.toString()));
      }

      return list;
    }
    try {
      if (value.contains(".")) {
        return Double.parseDouble(value);
      } else {
        return Long.parseLong(value);
      }
    } catch (NumberFormatException e) {
      return value;
    }
  }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// It deploys the "Discovery Packet" pipeline to your Extend account.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   go run provision.go
//
// Generated by doc1 (template: discovery-packet-splitter).

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
)

const (
	API     = "https://api.extend.ai"
	VERSION = "2026-02-09"
)

var (
	apiKey  string
	stateDir  string
	stateFile string
)

type State struct {
	WorkflowID string `json:"workflowId,omitempty"`
}

type WorkflowStep struct {
	Name   string        `json:"name"`
	Type   string        `json:"type"`
	Config interface{}   `json:"config,omitempty"`
	Next   []interface{} `json:"next,omitempty"`
}

type Workflow struct {
	Name  string         `json:"name"`
	Steps []WorkflowStep `json:"steps"`
}

type WorkflowListItem struct {
	Name string `json:"name,omitempty"`
	ID   string `json:"id,omitempty"`
}

type WorkflowListResponse struct {
	Data  []WorkflowListItem `json:"data,omitempty"`
	Items []WorkflowListItem `json:"items,omitempty"`
}

type WorkflowCreateResponse struct {
	ID       string `json:"id,omitempty"`
	Workflow struct {
		ID string `json:"id,omitempty"`
	} `json:"workflow,omitempty"`
}

func init() {
	apiKey = os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		fmt.Fprintf(os.Stderr, "Set EXTEND_API_KEY first.\n")
		os.Exit(1)
	}

	cwd, err := os.Getwd()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to get working directory: %v\n", err)
		os.Exit(1)
	}
	stateDir = filepath.Join(cwd, ".extend")
	stateFile = filepath.Join(stateDir, "discovery-packet-splitter.json")
}

func loadState() (State, error) {
	var state State
	data, err := os.ReadFile(stateFile)
	if err != nil {
		if os.IsNotExist(err) {
			return state, nil
		}
		return state, err
	}
	err = json.Unmarshal(data, &state)
	return state, err
}

func saveState(state State) error {
	if err := os.MkdirAll(stateDir, 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(stateFile, data, 0644)
}

func apiCall(method, pathName string, body interface{}) (json.RawMessage, error) {
	url := API + pathName
	var reqBody io.Reader
	if body != nil {
		data, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reqBody = bytes.NewReader(data)
	}

	req, err := http.NewRequest(method, url, reqBody)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
	req.Header.Set("x-extend-api-version", VERSION)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode >= 400 {
		errMsg := string(respBody)
		if len(errMsg) > 300 {
			errMsg = errMsg[:300]
		}
		return nil, fmt.Errorf("%s %s failed (%d): %s", method, pathName, resp.StatusCode, errMsg)
	}

	return respBody, nil
}

func buildWorkflow() Workflow {
	return Workflow{
		Name: "Discovery Packet Processing Pipeline",
		Steps: []WorkflowStep{
			{
				Name: "startTrigger1",
				Type: "TRIGGER",
				Next: []interface{}{
					map[string]string{"step": "parse1"},
				},
			},
			{
				Name: "parse1",
				Type: "PARSE",
				Config: map[string]interface{}{
					"parseConfig": map[string]interface{}{
						"blockOptions": map[string]interface{}{
							"text": map[string]interface{}{
								"agentic": map[string]bool{
									"enabled": true,
								},
								"signatureDetectionEnabled": true,
							},
							"tables": map[string]interface{}{
								"agentic": map[string]bool{
									"enabled": true,
								},
								"tableHeaderContinuationEnabled": true,
							},
							"figures": map[string]interface{}{
								"enabled": true,
							},
						},
						"chunkingStrategy": map[string]interface{}{
							"type":    "page",
							"options": map[string]interface{}{},
						},
					},
				},
				Next: []interface{}{
					map[string]string{"step": "split2"},
				},
			},
			{
				Name: "split2",
				Type: "SPLIT",
				Config: map[string]interface{}{
					"splitterConfig": map[string]interface{}{
						"splitClassifications": []map[string]string{
							{
								"id":          "splitter_classification1",
								"type":        "other",
								"description": "Use the `other` document type when the document can not clearly be classified into one of the described classifications.",
							},
							{
								"id":          "subdocument_type_hL_",
								"type":        "response to request for admission",
								"description": "A \"response to request for admission\" is the answering party's verified written reply to requests for admission. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the order of the requests, each admitting, denying, or stating an inability to admit or deny after reasonable inquiry, followed by perjury verification language and a signature block.",
							},
							{
								"id":          "subdocument_type_lIz",
								"type":        "form interrogatories - general",
								"description": "A \"form interrogatories - general\" is a Judicial Council form (DISC-001) used in unlimited civil cases in which the asking party checks boxes next to pre-approved written questions the answering party must answer under oath. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and numbered interrogatory categories covering background, insurance, injuries, damages, medical history, prior claims, investigation, contentions, motor vehicle facts, and contract terms.",
							},
							{
								"id":          "subdocument_type_UMs",
								"type":        "form interrogatories - limited civil",
								"description": "A \"form interrogatories - limited civil\" is a Judicial Council form (DISC-004) used in limited civil cases subject to economic litigation discovery limits. It contains a caption with party and case information, a set number, a definition of \"INCIDENT,\" and checkbox-selected interrogatories in the 100-series, which contain no subparts and cover identity, background, insurance, injuries, property damage, income loss, medical history, claims and defenses, contentions, motor vehicle facts, and contract terms.",
							},
							{
								"id":          "subdocument_type_H6Q",
								"type":        "response to form interrogatories",
								"description": "A \"response to form interrogatories\" is the answering party's verified written reply to Judicial Council form interrogatories. It contains a pleading caption, propounding and responding party designations, a set number, an indication that the responses are to Form interrogatories, and numbered answers with lettered subparts matching the numbering of the questions propounded, followed by perjury verification language and a signature block.",
							},
							{
								"id":          "subdocument_type_rgQ",
								"type":        "request for production",
								"description": "A \"request for production\" is a discovery demand requiring another party to produce, or permit inspection and copying of, documents or tangible things. It contains a pleading caption, propounding and responding party designations, a set number, a demand paragraph, a place-and-time-of-production provision selecting one of three production methods, and numbered categories of documents or things to be produced.",
							},
							{
								"id":          "subdocument_type_Oh3",
								"type":        "response to request for production",
								"description": "A \"response to request for production\" is the responding party's verified written reply to a demand for documents or things. It contains a pleading caption, propounding and responding party designations, a set number, and numbered responses matching the demands, each stating that production will be allowed in whole or in part or that the party is unable to comply because the items never existed, were lost or destroyed, or are no longer in the party's possession, including \"diligent search and reasonable inquiry\" language and a perjury verification.",
							},
							{
								"id":          "subdocument_type_D8q",
								"type":        "discovery remedies reference",
								"description": "A \"discovery remedies reference\" is an instructional reference chart, not a pleading, summarizing procedural options when a party fails to meet discovery obligations. It contains scenario rows describing no response, incomplete or meritless responses, noncompliance with a court order, and excessive discovery, mapped to the applicable request types, meet-and-confer requirements, available motions, deadlines, sanction limits, and discovery quantity limits for limited and unlimited civil cases.",
							},
						},
						"baseProcessor": "splitting_performance",
						"advancedOptions": map[string]bool{
							"pageOverlapEnabled": false,
						},
					},
				},
			},
		},
	}
}

func main() {
	workflow := buildWorkflow()
	fmt.Printf("Deploying \"%s\"…\n", workflow.Name)

	state, err := loadState()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to load state: %v\n", err)
		os.Exit(1)
	}

	if state.WorkflowID != "" {
		fmt.Printf("✓ workflow already provisioned (%s) — updating steps\n", state.WorkflowID)
		_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", state.WorkflowID), map[string]interface{}{"steps": workflow.Steps})
		if err != nil {
			fmt.Fprintf(os.Stderr, "Failed to update workflow: %v\n", err)
			os.Exit(1)
		}
	} else {
		// Try to find an existing workflow with the same name
		listURL := fmt.Sprintf("/workflows?name=%s", url.QueryEscape(workflow.Name))
		respData, err := apiCall("GET", listURL, nil)
		if err == nil {
			var listResp WorkflowListResponse
			if err := json.Unmarshal(respData, &listResp); err == nil {
				items := listResp.Data
				if len(items) == 0 {
					items = listResp.Items
				}
				for _, item := range items {
					if item.Name == workflow.Name && item.ID != "" {
						state.WorkflowID = item.ID
						if err := saveState(state); err != nil {
							fmt.Fprintf(os.Stderr, "Failed to save state: %v\n", err)
							os.Exit(1)
						}
						fmt.Printf("✓ workflow \"%s\" found in your account (%s) — updating steps\n", workflow.Name, item.ID)
						_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", item.ID), map[string]interface{}{"steps": workflow.Steps})
						if err != nil {
							fmt.Fprintf(os.Stderr, "Failed to update workflow: %v\n", err)
							os.Exit(1)
						}
						break
					}
				}
			}
		}

		if state.WorkflowID == "" {
			respData, err := apiCall("POST", "/workflows", workflow)
			if err != nil {
				fmt.Fprintf(os.Stderr, "Failed to create workflow: %v\n", err)
				os.Exit(1)
			}

			var created WorkflowCreateResponse
			if err := json.Unmarshal(respData, &created); err != nil {
				fmt.Fprintf(os.Stderr, "Failed to parse response: %v\n", err)
				os.Exit(1)
			}

			wfID := created.ID
			if wfID == "" {
				wfID = created.Workflow.ID
			}
			if wfID == "" {
				fmt.Fprintf(os.Stderr, "Could not read created workflow id from response\n")
				os.Exit(1)
			}

			state.WorkflowID = wfID
			if err := saveState(state); err != nil {
				fmt.Fprintf(os.Stderr, "Failed to save state: %v\n", err)
				os.Exit(1)
			}
			fmt.Printf("+ created workflow (%s)\n", wfID)
		}
	}

	// Deploy the current draft as a new version (best-effort)
	apiCall("POST", fmt.Sprintf("/workflows/%s/versions", state.WorkflowID), map[string]interface{}{})

	fmt.Println("\nDone. Run documents through it with:")
	fmt.Printf("  POST %s/workflow_runs  { workflow: { id: \"%s\" }, file: { url: \"https://…\" } }\n", API, state.WorkflowID)
	fmt.Println("Or open the workflow in the Extend dashboard to review and deploy it.")
}

Frequently Asked Questions (FAQ)

Use an enum array in the schema (`{ type: "array", items: { type: "object", properties: { number: string, status: { enum: ["admit", "deny", "lack_knowledge"] }, explanation: { type: ["string", "null"] } } } }`) so responses normalize and downstream systems can process reliably. Free text requires post-extraction NLP normalization and introduces 10–15% classification errors.
Extract confidence scores for each admission item; flag any item with `< 0.85` confidence or any admission with explanatory text for human review, as legal admissions require 99%+ accuracy. Set a batch rule: if > 20% of items are flagged, escalate the entire RFA document.
Yes—always use `agentic_ocr` mode for RFA extraction regardless of source; it handles both cleanly and detects complex legal numbering/nested structures that light mode misses.
Tags
Legal DiscoveryCivil LitigationCourt DocumentAdmissions Request
About this template

A discovery packet is a compiled set of the written discovery documents exchanged between parties in civil litigation, including sample and annotated versions of each form. This packet contains a Request for Admission (DISC-020) with its attachments, Form Interrogatories—General (DISC-001), Form Interrogatories—Limited Civil Cases (DISC-004), Special Interrogatories, Requests for Production of Documents or Things, the corresponding verified responses to each, and a reference chart of remedies for discovery disputes. This template captures attorney/party information, court name and branch, case identification, propounding and responding party designations, set numbers, the substantive requests or answers within each document, and the statutory guidelines, verification language, and response requirements that accompany them.

Document formats
  • PDF
  • Images & Scans
  • Word / DOCX
Requirements
  • Checkboxes & Strikethroughs
  • Complex layouts
  • Scanned documents
  • Handwriting
  • Long tables
  • 1k+ pages