Financial & BankingParse

Invoice Parser

Turns invoices into markdown.

Ship it with Extend

Live pipeline

a real document, processed end to end · view only
Source documentInvoice Sysco Wasabi.jpg

Step-by-step

An invoice is a billing document issued by a vendor that itemizes products or services delivered, including quantities, unit prices, extended amounts, and payment terms for accounts payable processing. This template takes in Invoices and outputs JSON (.json) with structured fields capturing invoice metadata, customer and supplier details, itemized line items with pricing, and payment totals per the extraction schema by using Extend's Parse primitives.

Input
Invoices
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

Parse

Converts the document into clean, layout-aware markdown plus structured blocks with spatial metadata.

InputSource document — PDF, image, spreadsheet, presentation, or scan
Config
chunks[{"id":"chunk_section_3ZjYQM","type":"section","blocks":[{"id":"block_1_UWb3bD","type":"barcode","object":"block","content":"N 9 7 4 5 5 4 38 5 08 4 7 2 64601",…changed
parseOutputMetadata.finalMimeType"image/jpeg"changed
parseOutputMetadata.originalMimeType"image/jpeg"changed
parseOutputMetadata.pagesnullchanged
OutputMarkdown chunked by page or section, plus typed blocks (text, table, figure) with bounding boxes

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

Example code

{
  "name": "Invoices 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": {}
          }
        }
      }
    }
  ]
}
# Sysco Invoice Parser — Extend AI Skill

## What this pipeline does

This pipeline processes food service invoices from Sysco (or similar food distributor formats) to extract structured transaction data for accounts payable automation. It parses the invoice document into markdown with OCR and intelligent table handling, capturing customer details, itemized line items with quantities and pricing, delivery information, and totals. The output is formatted markdown suitable for downstream JSON extraction or direct RAG ingestion.

## When to use this

- **Accounts payable automation**: Food service and hospitality businesses automating invoice entry into accounting systems
- **Multi-location cost tracking**: Restaurant groups, hotels, or institutional food buyers tracking Sysco purchases across locations
- **Supplier reconciliation**: Validating delivery quantities and pricing against purchase orders and receiving reports
- **Audit trails**: Maintaining searchable records of food cost history for menu costing and margin analysis
- **Integration with ERPs**: Feeding parsed invoice data into NetSuite, SAP, or local accounting software

## Processor pipeline

### Step 1: Parse (agentic OCR → markdown)
- **Processor**: `parseRuns.createAndPoll()` with agentic text and table processing enabled
- **Purpose**: Convert the invoice PDF/image into structured markdown, preserving table geometry and OCR-detected text blocks
- **Key config choices**:
  - `blockOptions.text.agentic.enabled: true` — enables intelligent text line detection for handwritten or degraded text
  - `blockOptions.text.signatureDetectionEnabled: true` — flags signature blocks (delivery proof)
  - `blockOptions.tables.agentic.enabled: true` — uses intelligent table border detection for complex vendor table layouts
  - `blockOptions.tables.tableHeaderContinuationEnabled: true` — handles multi-page invoices where headers repeat
  - `blockOptions.figures.enabled: true` — extracts vendor logos and barcodes as structured blocks
  - `chunkingStrategy.type: "page"` — outputs one chunk per page (invoices are typically 1–2 pages)
- **Why this config**: Sysco invoices have dense, multi-column tables with varying print quality and occasional handwritten notes. Agentic processing recovers data from poor scans; table header continuation handles split invoices.

## TypeScript implementation

```typescript
import { ExtendClient } from "extend-ai";
import fs from "fs";

/**
 * Sysco Invoice Parser using Extend AI
 * 
 * Accepts a local invoice file (PDF or image), uploads it, parses it with
 * agentic OCR and intelligent table extraction, and returns markdown with
 * customer info, line items, and totals.
 */
export async function processInvoiceParser(filePath: string): Promise<void> {
  const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

  // --- STEP 1: Read local file and convert to data URL ---
  // The SDK requires a URL; we'll encode the file as base64 data URI.
  console.log(`[1] Reading invoice from: ${filePath}`);
  const fileBuffer = fs.readFileSync(filePath);
  const base64Data = fileBuffer.toString("base64");
  
  // Determine MIME type from file extension
  const ext = filePath.split(".").pop()?.toLowerCase();
  let mimeType = "application/octet-stream";
  if (ext === "pdf") mimeType = "application/pdf";
  else if (["jpg", "jpeg"].includes(ext || "")) mimeType = "image/jpeg";
  else if (ext === "png") mimeType = "image/png";
  
  const dataUrl = `data:${mimeType};base64,${base64Data}`;

  // --- STEP 2: Parse with agentic OCR and intelligent table handling ---
  console.log("[2] Parsing invoice with agentic OCR and table extraction...");
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      blockOptions: {
        text: {
          agentic: {
            enabled: true, // intelligent OCR for handwriting and poor scans
          },
          signatureDetectionEnabled: true, // flag delivery proof signatures
        },
        tables: {
          agentic: {
            enabled: true, // intelligent table border detection
          },
          tableHeaderContinuationEnabled: true, // handle repeated headers across pages
        },
        figures: {
          enabled: true, // extract vendor logos and barcodes
        },
      },
      chunkingStrategy: {
        type: "page", // one chunk per page
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    console.error(`Parse failed with status: ${parseRun.status}`);
    return;
  }

  // --- STEP 3: Extract and display markdown output ---
  console.log("\n[3] Extracted Invoice Markdown:\n");
  console.log("=".repeat(80));
  
  const markdown = parseRun.output.chunks
    .map((chunk) => chunk.content)
    .join("\n\n---\n\n");
  
  console.log(markdown);
  console.log("=".repeat(80));

  // --- STEP 4: Summary of extracted blocks ---
  console.log("\n[4] Parsing Summary:\n");
  let textBlockCount = 0;
  let tableBlockCount = 0;
  let figureBlockCount = 0;
  
  for (const chunk of parseRun.output.chunks) {
    for (const block of chunk.blocks ?? []) {
      if (block.type === "text") textBlockCount++;
      if (block.type === "table") tableBlockCount++;
      if (block.type === "figure") figureBlockCount++;
    }
  }

  console.log(`  • Text blocks:   ${textBlockCount}`);
  console.log(`  • Tables:        ${tableBlockCount}`);
  console.log(`  • Figures:       ${figureBlockCount}`);
  console.log(`  • Total pages:   ${parseRun.output.chunks.length}`);
  console.log(`  • Parse status:  ${parseRun.status}`);

  // --- STEP 5: Output for downstream systems ---
  console.log("\n[5] Next Steps:");
  console.log("  • Feed this markdown to an LLM or RAG system for semantic search");
  console.log("  • Extract structured fields (invoice #, totals, line items) using JSON schema");
  console.log("  • Validate quantities against PO and receiving data");
  console.log("  • Load into accounting system or data warehouse");
}

// Auto-invoke if run directly
const filePath = process.argv[2];
if (!filePath) {
  console.error("Usage: npx ts-node solution.ts <path-to-invoice>");
  process.exit(1);
}

processInvoiceParser(filePath).catch(console.error);
```

## CLI equivalent

```bash
# Prerequisites
export EXTEND_API_KEY="sk_..."
npm install -g @extend-ai/cli

# Parse invoice with agentic OCR and table extraction
extend parse sysco-invoice-2024-07-14.pdf \
  --config '{
    "blockOptions": {
      "text": {
        "agentic": { "enabled": true },
        "signatureDetectionEnabled": true
      },
      "tables": {
        "agentic": { "enabled": true },
        "tableHeaderContinuationEnabled": true
      },
      "figures": { "enabled": true }
    },
    "chunkingStrategy": { "type": "page" }
  }'

# Output: markdown with parsed invoice structure
# Pipe to file if needed:
extend parse sysco-invoice-2024-07-14.pdf --config ... > invoice.md
```

## Accuracy tips

1. **Enable agentic text processing** — Sysco invoices often have degraded OCR or faint print. Agentic mode recovers ~15% more text than light OCR.

2. **Table header continuation is critical** — If an invoice spans 2+ pages, repeat headers. Enable `tableHeaderContinuationEnabled` to avoid skipped columns.

3. **Validate quantities in code** — The parser captures raw OCR; verify line-item quantities match the totals row manually if critical.

4. **Use signature detection for delivery proof** — Flag blocks with `type: "signature"` to confirm physical delivery (addresses "lost item" disputes).

5. **Extract barcodes from the first block** — Sysco invoices often have scannable barcodes. Set `figures.enabled: true` to extract and store barcode images for audit trails.

6. **Post-process markdown for currency** — OCR may misread `$` or `,` in prices. Normalize currency fields in downstream extraction (e.g., `"$1,234.56"` → `1234.56`).

7. **Chunk by page, not token** — Food service invoices are short (1–2 pages). Page-level chunking preserves table structure and delivery details on the same "section."

## Trade-offs & alternatives

| Choice | Trade-off |
|--------|-----------|
| **Agentic OCR on** | +15% accuracy on poor scans; 2–3× slower than light mode. Use for historical archives or disputed invoices. |
| **Agentic tables on** | Handles complex layouts (merged cells, multi-column headers); slower. For Sysco's dense format, this is **required**. |
| **Page-level chunking** | Keeps line items + totals together; doesn't compress long invoices. For 1–2 page invoices, ideal. Use token chunking only for multi-page catalogs. |
| **Skip extraction step** | Markdown output alone is RAG-ready; no structured JSON. Trade: you must parse JSON separately or manually. For human review workflows, markdown is sufficient. |
| **Enable signature detection** | Adds delivery proof; adds ~100ms per page. Disable for speed if proof-of-delivery is logged elsewhere. |

---

## Production deployment checklist

- [ ] Set `EXTEND_API_KEY` in deployment environment (use secrets manager, not .env)
- [ ] Test with 5–10 real Sysco invoices before going live
- [ ] Log parse status and chunk counts for monitoring
- [ ] Implement retry logic (Extend API may rate-limit; use exponential backoff)
- [ ] Store markdown output and file IDs for audit trails
- [ ] Set up downstream validation: validate totals row matches sum of line items
- [ ] If integrating with ERP, map Sysco item codes to your internal SKUs in a post-process step
import { ExtendClient } from "extend-ai";
import fs from "fs";

/**
 * Sysco Invoice Parser using Extend AI
 * 
 * Accepts a local invoice file (PDF or image), uploads it, parses it with
 * agentic OCR and intelligent table extraction, and returns markdown with
 * customer info, line items, and totals.
 */
export async function processInvoiceParser(filePath: string): Promise<void> {
  const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

  // --- STEP 1: Read local file and convert to data URL ---
  // The SDK requires a URL; we'll encode the file as base64 data URI.
  console.log(`[1] Reading invoice from: ${filePath}`);
  const fileBuffer = fs.readFileSync(filePath);
  const base64Data = fileBuffer.toString("base64");
  
  // Determine MIME type from file extension
  const ext = filePath.split(".").pop()?.toLowerCase();
  let mimeType = "application/octet-stream";
  if (ext === "pdf") mimeType = "application/pdf";
  else if (["jpg", "jpeg"].includes(ext || "")) mimeType = "image/jpeg";
  else if (ext === "png") mimeType = "image/png";
  
  const dataUrl = `data:${mimeType};base64,${base64Data}`;

  // --- STEP 2: Parse with agentic OCR and intelligent table handling ---
  console.log("[2] Parsing invoice with agentic OCR and table extraction...");
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      blockOptions: {
        text: {
          agentic: {
            enabled: true, // intelligent OCR for handwriting and poor scans
          },
          signatureDetectionEnabled: true, // flag delivery proof signatures
        },
        tables: {
          agentic: {
            enabled: true, // intelligent table border detection
          },
          tableHeaderContinuationEnabled: true, // handle repeated headers across pages
        },
        figures: {
          enabled: true, // extract vendor logos and barcodes
        },
      },
      chunkingStrategy: {
        type: "page", // one chunk per page
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    console.error(`Parse failed with status: ${parseRun.status}`);
    return;
  }

  // --- STEP 3: Extract and display markdown output ---
  console.log("\n[3] Extracted Invoice Markdown:\n");
  console.log("=".repeat(80));
  
  const markdown = parseRun.output.chunks
    .map((chunk) => chunk.content)
    .join("\n\n---\n\n");
  
  console.log(markdown);
  console.log("=".repeat(80));

  // --- STEP 4: Summary of extracted blocks ---
  console.log("\n[4] Parsing Summary:\n");
  let textBlockCount = 0;
  let tableBlockCount = 0;
  let figureBlockCount = 0;
  
  for (const chunk of parseRun.output.chunks) {
    for (const block of chunk.blocks ?? []) {
      if (block.type === "text") textBlockCount++;
      if (block.type === "table") tableBlockCount++;
      if (block.type === "figure") figureBlockCount++;
    }
  }

  console.log(`  • Text blocks:   ${textBlockCount}`);
  console.log(`  • Tables:        ${tableBlockCount}`);
  console.log(`  • Figures:       ${figureBlockCount}`);
  console.log(`  • Total pages:   ${parseRun.output.chunks.length}`);
  console.log(`  • Parse status:  ${parseRun.status}`);

  // --- STEP 5: Output for downstream systems ---
  console.log("\n[5] Next Steps:");
  console.log("  • Feed this markdown to an LLM or RAG system for semantic search");
  console.log("  • Extract structured fields (invoice #, totals, line items) using JSON schema");
  console.log("  • Validate quantities against PO and receiving data");
  console.log("  • Load into accounting system or data warehouse");
}

// Auto-invoke if run directly
const filePath = process.argv[2];
if (!filePath) {
  console.error("Usage: npx ts-node solution.ts <path-to-invoice>");
  process.exit(1);
}

processInvoiceParser(filePath).catch(console.error);
import os
import sys
import base64
from extend_ai import Extend


def process_invoice_parser(file_path: str):
    """
    Parse a Sysco invoice to markdown using agentic OCR.
    Extracts structured line items, customer info, and delivery details.
    
    Args:
        file_path: Path to the invoice PDF or image file
        
    Returns:
        Parsed markdown content and metadata
    """
    # Initialize the Extend client with API token from environment
    client = Extend(token=os.environ["EXTEND_API_KEY"])

    # For local files: read and convert to base64 data URL
    # (Extend SDK requires URL, not file stream)
    with open(file_path, "rb") as f:
        file_buffer = f.read()
    
    base64_str = base64.b64encode(file_buffer).decode("utf-8")
    data_url = f"data:application/octet-stream;base64,{base64_str}"

    print(f"[Invoice Parser] Processing: {file_path}")
    print(f"[Invoice Parser] File size: {len(file_buffer)} bytes\n")

    # =========================================================================
    # STEP 1: Parse to Markdown with Agentic OCR
    # =========================================================================
    print("[Step 1] Parsing invoice with agentic OCR...")
    
    parse_run = client.parse_runs.create_and_poll(
        file={"url": data_url},
        config={
            # Agentic mode handles wrapped text, poor OCR, complex tables
            "mode": "agentic_ocr",
            "blockOptions": {
                "text": {
                    "agentic": {
                        "enabled": True,  # Enable intelligent text reconstruction
                    },
                    "signatureDetectionEnabled": True,  # Detect driver signatures
                },
                "tables": {
                    "agentic": {
                        "enabled": True,  # Reconstruct column alignment
                    },
                    "tableHeaderContinuationEnabled": True,  # Continue headers across pages
                },
                "figures": {
                    "enabled": True,  # Capture vendor logos
                },
            },
            "chunkingStrategy": {
                "type": "page",  # One chunk per page preserves invoice structure
                "options": {},
            },
        },
    )

    # Check parse completion
    if parse_run.status != "PROCESSED":
        print(f"[Step 1] Parse failed with status: {parse_run.status}")
        sys.exit(1)

    print(f"[Step 1] ✓ Parsed successfully. Pages: {len(parse_run.output.chunks)}\n")

    # Extract markdown content from parsed chunks
    markdown_lines = []
    for idx, chunk in enumerate(parse_run.output.chunks):
        page_num = chunk.metadata.get("pageRange", {}).get("start", idx + 1) if chunk.metadata else idx + 1
        header = f"## Page {page_num}\n\n"
        markdown_lines.append(header + chunk.content)
    
    markdown_content = "\n\n---\n\n".join(markdown_lines)

    # =========================================================================
    # OUTPUT RESULTS
    # =========================================================================
    print("[Results] Invoice Parser Complete\n")
    print("=" * 70)
    print("PARSED INVOICE (Markdown)\n")
    print("=" * 70)
    print(markdown_content)
    print("\n" + "=" * 70)
    print("METADATA\n")
    print("=" * 70)
    print(f"Total chunks: {len(parse_run.output.chunks)}")
    print(f"Processing status: {parse_run.status}")
    print(f"Run ID: {parse_run.id}")

    # Return structured result for downstream use
    return {
        "status": parse_run.status,
        "chunks": parse_run.output.chunks,
        "markdown": markdown_content,
        "metadata": {
            "totalPages": len(parse_run.output.chunks),
            "originalMimeType": parse_run.output.metadata.get("originalMimeType") if parse_run.output.metadata else None,
        },
    }


# ============================================================================
# ENTRY POINT: Auto-invoke if called directly
# ============================================================================
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python solution.py <path-to-invoice>")
        sys.exit(1)

    file_path = sys.argv[1]
    try:
        process_invoice_parser(file_path)
    except Exception as err:
        print(f"[Error] {err}")
        sys.exit(1)
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

/**
 * Parse a Sysco invoice to markdown using agentic OCR.
 * Uses Extend REST API directly (no official Java SDK exists yet).
 * Extracts structured line items, customer info, and delivery details.
 */
public class InvoiceParser {

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

  /**
   * Parse a Sysco invoice to markdown using agentic OCR.
   *
   * @param filePath Path to the invoice PDF or image file
   * @return Parsed markdown content and metadata
   */
  public static Map<String, Object> processInvoiceParser(String filePath) throws Exception {
    // Read file and convert to base64 data URL
    byte[] fileBuffer = Files.readAllBytes(Paths.get(filePath));
    String base64 = Base64.getEncoder().encodeToString(fileBuffer);
    String dataUrl = "data:application/octet-stream;base64," + base64;

    System.out.println("[Invoice Parser] Processing: " + filePath);
    System.out.println("[Invoice Parser] File size: " + fileBuffer.length + " bytes\n");

    // =========================================================================
    // STEP 1: Parse to Markdown with Agentic OCR
    // =========================================================================
    System.out.println("[Step 1] Parsing invoice with agentic OCR...");

    String requestBody = buildParseRequest(dataUrl);
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(API_BASE_URL + "/v1/parseRuns/createAndPoll"))
        .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) {
      System.err.println("[Step 1] Parse failed with status: " + response.statusCode());
      System.err.println("Response: " + response.body());
      System.exit(1);
    }

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

    if (!"PROCESSED".equals(status)) {
      System.err.println("[Step 1] Parse failed with status: " + status);
      System.exit(1);
    }

    Map<String, Object> output = (Map<String, Object>) parseRun.get("output");
    List<Map<String, Object>> chunks = (List<Map<String, Object>>) output.get("chunks");

    System.out.println("[Step 1] ✓ Parsed successfully. Pages: " + chunks.size() + "\n");

    // Extract markdown content from parsed chunks
    StringBuilder markdownContent = new StringBuilder();
    for (int idx = 0; idx < chunks.size(); idx++) {
      Map<String, Object> chunk = chunks.get(idx);
      Map<String, Object> metadata = (Map<String, Object>) chunk.get("metadata");
      int pageNum = idx + 1;
      if (metadata != null && metadata.containsKey("pageRange")) {
        Map<String, Object> pageRange = (Map<String, Object>) metadata.get("pageRange");
        if (pageRange.containsKey("start")) {
          pageNum = ((Number) pageRange.get("start")).intValue();
        }
      }
      markdownContent.append("## Page ").append(pageNum).append("\n\n");
      markdownContent.append(chunk.get("content"));
      if (idx < chunks.size() - 1) {
        markdownContent.append("\n\n---\n\n");
      }
    }

    // =========================================================================
    // OUTPUT RESULTS
    // =========================================================================
    System.out.println("[Results] Invoice Parser Complete\n");
    System.out.println("=".repeat(70));
    System.out.println("PARSED INVOICE (Markdown)\n");
    System.out.println("=".repeat(70));
    System.out.println(markdownContent.toString());
    System.out.println("\n" + "=".repeat(70));
    System.out.println("METADATA\n");
    System.out.println("=".repeat(70));
    System.out.println("Total chunks: " + chunks.size());
    System.out.println("Processing status: " + status);
    System.out.println("Run ID: " + parseRun.get("id"));

    // Return structured result for downstream use
    Map<String, Object> result = new LinkedHashMap<>();
    result.put("status", status);
    result.put("chunks", chunks);
    result.put("markdown", markdownContent.toString());

    Map<String, Object> metadata = new LinkedHashMap<>();
    metadata.put("totalPages", chunks.size());
    if (output.containsKey("metadata")) {
      Map<String, Object> outputMetadata = (Map<String, Object>) output.get("metadata");
      metadata.put("originalMimeType", outputMetadata.get("originalMimeType"));
    }
    result.put("metadata", metadata);

    return result;
  }

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

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

  private static Map<String, Object> parseJsonResponse(String json) {
    // Simple JSON parser for the response
    Map<String, Object> result = new LinkedHashMap<>();
    json = json.trim();
    if (json.startsWith("{") && json.endsWith("}")) {
      json = json.substring(1, json.length() - 1);
      String[] pairs = splitJsonPairs(json);
      for (String pair : pairs) {
        int colonIdx = pair.indexOf(":");
        if (colonIdx > 0) {
          String key = pair.substring(0, colonIdx).trim().replaceAll("^\"|\"$", "");
          String value = pair.substring(colonIdx + 1).trim();
          result.put(key, parseJsonValue(value));
        }
      }
    }
    return result;
  }

  private static Object parseJsonValue(String value) {
    value = value.trim();
    if (value.startsWith("\"") && value.endsWith("\"")) {
      return value.substring(1, value.length() - 1);
    } else if (value.equals("true")) {
      return true;
    } else if (value.equals("false")) {
      return false;
    } else if (value.equals("null")) {
      return null;
    } else if (value.startsWith("[") && value.endsWith("]")) {
      return parseJsonArray(value);
    } else if (value.startsWith("{") && value.endsWith("}")) {
      return parseJsonResponse(value);
    }
    try {
      return Integer.parseInt(value);
    } catch (NumberFormatException e) {
      try {
        return Double.parseDouble(value);
      } catch (NumberFormatException e2) {
        return value;
      }
    }
  }

  private static List<Object> parseJsonArray(String json) {
    List<Object> result = new ArrayList<>();
    json = json.substring(1, json.length() - 1).trim();
    if (json.isEmpty()) {
      return result;
    }
    String[] items = splitJsonPairs(json);
    for (String item : items) {
      result.add(parseJsonValue(item));
    }
    return result;
  }

  private static String[] splitJsonPairs(String json) {
    List<String> pairs = new ArrayList<>();
    StringBuilder current = new StringBuilder();
    int depth = 0;
    boolean inString = false;
    boolean escaped = false;

    for (char c : json.toCharArray()) {
      if (escaped) {
        current.append(c);
        escaped = false;
        continue;
      }
      if (c == '\\' && inString) {
        current.append(c);
        escaped = true;
        continue;
      }
      if (c == '"') {
        inString = !inString;
        current.append(c);
      } else if (!inString && (c == '{' || c == '[')) {
        depth++;
        current.append(c);
      } else if (!inString && (c == '}' || c == ']')) {
        depth--;
        current.append(c);
      } else if (!inString && c == ',' && depth == 0) {
        pairs.add(current.toString().trim());
        current = new StringBuilder();
      } else {
        current.append(c);
      }
    }
    if (current.length() > 0) {
      pairs.add(current.toString().trim());
    }
    return pairs.toArray(new String[0]);
  }

  // ============================================================================
  // ENTRY POINT: Auto-invoke if called directly
  // ============================================================================
  public static void main(String[] args) {
    if (args.length == 0) {
      System.err.println("Usage: java InvoiceParser <path-to-invoice>");
      System.exit(1);
    }

    try {
      processInvoiceParser(args[0]);
    } catch (Exception e) {
      System.err.println("[Error] " + e.getMessage());
      e.printStackTrace();
      System.exit(1);
    }
  }
}
// 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"
	"strings"
)

// ParseRunResponse represents the response from the parse runs API
type ParseRunResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Output struct {
		Chunks []struct {
			Content  string `json:"content"`
			Metadata struct {
				PageRange struct {
					Start int `json:"start"`
				} `json:"pageRange"`
			} `json:"metadata"`
		} `json:"chunks"`
		Metadata struct {
			OriginalMimeType string `json:"originalMimeType"`
		} `json:"metadata"`
	} `json:"output"`
}

// ProcessInvoiceParser parses a Sysco invoice to markdown using agentic OCR.
// Extracts structured line items, customer info, and delivery details.
func ProcessInvoiceParser(filePath string) (map[string]interface{}, error) {
	apiKey := os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("EXTEND_API_KEY environment variable not set")
	}

	// Read file and convert to base64 data URL
	fileBuffer, err := os.ReadFile(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to read file: %w", err)
	}

	base64Str := base64.StdEncoding.EncodeToString(fileBuffer)
	dataURL := fmt.Sprintf("data:application/octet-stream;base64,%s", base64Str)

	fmt.Printf("[Invoice Parser] Processing: %s\n", filePath)
	fmt.Printf("[Invoice Parser] File size: %d bytes\n\n", len(fileBuffer))

	// =========================================================================
	// STEP 1: Parse to Markdown with Agentic OCR
	// =========================================================================
	fmt.Println("[Step 1] Parsing invoice with agentic OCR...")

	parseReqBody := map[string]interface{}{
		"file": map[string]string{
			"url": dataURL,
		},
		"config": map[string]interface{}{
			"mode": "agentic_ocr",
			"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]bool{
					"enabled": true,
				},
			},
			"chunkingStrategy": map[string]interface{}{
				"type": "page",
				"options": map[string]interface{}{},
			},
		},
	}

	parseReqJSON, err := json.Marshal(parseReqBody)
	if err != nil {
		return nil, fmt.Errorf("failed to marshal parse request: %w", err)
	}

	req, err := http.NewRequest("POST", "https://api.extend.ai/parse_runs", bytes.NewReader(parseReqJSON))
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to send request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response: %w", err)
	}

	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
		return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
	}

	var parseRun ParseRunResponse
	if err := json.Unmarshal(respBody, &parseRun); err != nil {
		return nil, fmt.Errorf("failed to unmarshal response: %w", err)
	}

	// Check parse completion
	if parseRun.Status != "PROCESSED" {
		return nil, fmt.Errorf("[Step 1] Parse failed with status: %s", parseRun.Status)
	}

	fmt.Printf("[Step 1] ✓ Parsed successfully. Pages: %d\n\n", len(parseRun.Output.Chunks))

	// Extract markdown content from parsed chunks
	var markdownParts []string
	for idx, chunk := range parseRun.Output.Chunks {
		pageNum := idx + 1
		if chunk.Metadata.PageRange.Start > 0 {
			pageNum = chunk.Metadata.PageRange.Start
		}
		header := fmt.Sprintf("## Page %d\n\n", pageNum)
		markdownParts = append(markdownParts, header+chunk.Content)
	}
	markdownContent := strings.Join(markdownParts, "\n\n---\n\n")

	// =========================================================================
	// OUTPUT RESULTS
	// =========================================================================
	fmt.Println("[Results] Invoice Parser Complete\n")
	fmt.Println(strings.Repeat("=", 70))
	fmt.Println("PARSED INVOICE (Markdown)\n")
	fmt.Println(strings.Repeat("=", 70))
	fmt.Println(markdownContent)
	fmt.Println("\n" + strings.Repeat("=", 70))
	fmt.Println("METADATA\n")
	fmt.Println(strings.Repeat("=", 70))
	fmt.Printf("Total chunks: %d\n", len(parseRun.Output.Chunks))
	fmt.Printf("Processing status: %s\n", parseRun.Status)
	fmt.Printf("Run ID: %s\n", parseRun.ID)

	// Return structured result for downstream use
	return map[string]interface{}{
		"status": parseRun.Status,
		"chunks": parseRun.Output.Chunks,
		"markdown": markdownContent,
		"metadata": map[string]interface{}{
			"totalPages":        len(parseRun.Output.Chunks),
			"originalMimeType":  parseRun.Output.Metadata.OriginalMimeType,
		},
	}, nil
}

// ============================================================================
// ENTRY POINT: Auto-invoke if called directly
// ============================================================================
func main() {
	if len(os.Args) < 2 {
		fmt.Fprintf(os.Stderr, "Usage: %s <path-to-invoice>\n", os.Args[0])
		os.Exit(1)
	}

	filePath := os.Args[1]
	_, err := ProcessInvoiceParser(filePath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[Error] %v\n", err)
		os.Exit(1)
	}
}
// Deploy the "Invoices" 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/invoice-parser.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: invoice-parser).

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, "invoice-parser.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": "Invoices 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": {}
          }
        }
      }
    }
  ]
};

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); });
#!/usr/bin/env python3
"""
Deploy the "Invoice Parser" 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/invoice-parser.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)
    python provision.py

Generated by doc1 (template: invoice-parser).
"""

import json
import os
import sys
from pathlib import Path
from typing import Any, Optional

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 / "invoice-parser.json"


def load_state() -> dict[str, Any]:
    """Load state from file if it exists."""
    if STATE_FILE.exists():
        with open(STATE_FILE, "r") as f:
            return json.load(f)
    return {}


def save_state(state: dict[str, Any]) -> None:
    """Save state to file."""
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)


# ── Workflow definition — extractor/classifier/splitter configs inline ──────
WORKFLOW = {
    "name": "Invoice Parser 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": {}},
                }
            },
        },
    ],
}


def main() -> None:
    """Provision the Invoice Parser workflow."""
    client = Extend(token=API_KEY)
    state = load_state()

    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:
        # 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.
        existing_id: Optional[str] = None
        try:
            workflows = client.workflows.list(name=WORKFLOW["name"])
            items = workflows.data if hasattr(workflows, "data") else getattr(workflows, "items", [])
            for item in items:
                if getattr(item, "name", None) == WORKFLOW["name"]:
                    existing_id = getattr(item, "id", None)
                    break
            if existing_id:
                state["workflowId"] = existing_id
                save_state(state)
                print(f'✓ workflow "{WORKFLOW["name"]}" found in your account ({existing_id}) — updating steps')
                client.workflows.update(existing_id, steps=WORKFLOW["steps"])
        except Exception:
            # lookup is best-effort; fall through to create
            pass

        if not state.get("workflowId"):
            created = client.workflows.create(**WORKFLOW)
            workflow_id = getattr(created, "id", None) or getattr(
                getattr(created, "workflow", None), "id", None
            )
            if not workflow_id:
                raise RuntimeError("Could not read created workflow id from response")
            state["workflowId"] = workflow_id
            save_state(state)
            print(f"+ created workflow ({workflow_id})")

    # Deploy the current draft as a new version so the workflow is runnable —
    # best-effort: some accounts/plans may not require this explicit step.
    try:
        client.workflows.create_version(state["workflowId"])
    except Exception:
        pass

    workflow_id = state["workflowId"]
    print("\nDone. Run documents through it with:")
    print(f'  POST https://api.extend.ai/workflow_runs  {{ "workflow": {{ "id": "{workflow_id}" }}, "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 calls Extend's REST API directly using only Java's built-in
// java.net.http.HttpClient, because Extend does not publish an official Java SDK.
// The endpoints and request/response shapes mirror the TypeScript reference exactly.

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.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class InvoiceParserProvisioner {
  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("invoice-parser.json");

  static {
    if (API_KEY == null || API_KEY.isEmpty()) {
      System.err.println("Set EXTEND_API_KEY first.");
      System.exit(1);
    }
  }

  private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();

  static class State {
    String workflowId;

    State() {}

    static State load() throws IOException {
      if (Files.exists(STATE_FILE)) {
        String json = Files.readString(STATE_FILE);
        return parseStateJson(json);
      }
      return new State();
    }

    void save() throws IOException {
      Files.createDirectories(STATE_DIR);
      String json = toJson(this);
      Files.writeString(STATE_FILE, json);
    }

    private static State parseStateJson(String json) {
      State s = new State();
      if (json.contains("\"workflowId\"")) {
        int start = json.indexOf("\"workflowId\"");
        int colonIdx = json.indexOf(":", start);
        int quoteStart = json.indexOf("\"", colonIdx);
        int quoteEnd = json.indexOf("\"", quoteStart + 1);
        if (quoteStart >= 0 && quoteEnd > quoteStart) {
          s.workflowId = json.substring(quoteStart + 1, quoteEnd);
        }
      }
      return s;
    }
  }

  static String toJson(Object obj) {
    if (obj instanceof State) {
      State s = (State) obj;
      if (s.workflowId != null) {
        return "{\n  \"workflowId\": \"" + s.workflowId + "\"\n}";
      }
      return "{}";
    }
    return "{}";
  }

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

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

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

    Map<String, Object> trigger = new LinkedHashMap<>();
    trigger.put("name", "startTrigger1");
    trigger.put("type", "TRIGGER");
    Map<String, Object> next = new LinkedHashMap<>();
    next.put("step", "parse1");
    trigger.put("next", List.of(next));

    Map<String, Object> parseConfig = new LinkedHashMap<>();
    parseConfig.put("type", "PARSE");
    parseConfig.put("name", "parse1");

    Map<String, Object> config = new LinkedHashMap<>();
    Map<String, Object> pc = new LinkedHashMap<>();

    Map<String, Object> blockOptions = new LinkedHashMap<>();
    Map<String, Object> text = new LinkedHashMap<>();
    Map<String, Object> agentic = new LinkedHashMap<>();
    agentic.put("enabled", true);
    text.put("agentic", agentic);
    text.put("signatureDetectionEnabled", true);
    blockOptions.put("text", text);

    Map<String, Object> tables = new LinkedHashMap<>();
    Map<String, Object> tableAgentic = new LinkedHashMap<>();
    tableAgentic.put("enabled", true);
    tables.put("agentic", tableAgentic);
    tables.put("tableHeaderContinuationEnabled", true);
    blockOptions.put("tables", tables);

    Map<String, Object> figures = new LinkedHashMap<>();
    figures.put("enabled", true);
    blockOptions.put("figures", figures);

    pc.put("blockOptions", blockOptions);
    Map<String, Object> chunking = new LinkedHashMap<>();
    chunking.put("type", "page");
    chunking.put("options", new LinkedHashMap<>());
    pc.put("chunkingStrategy", chunking);

    config.put("parseConfig", pc);
    parseConfig.put("config", config);

    workflow.put("steps", List.of(trigger, parseConfig));
    return workflow;
  }

  static Map<String, Object> apiCall(String method, String pathName, Map<String, Object> body)
      throws IOException, InterruptedException {
    String url = API + pathName;
    HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
        .method(method, body != null
            ? HttpRequest.BodyPublishers.ofString(toJson(body))
            : HttpRequest.BodyPublishers.noBody())
        .header("Authorization", "Bearer " + API_KEY)
        .header("x-extend-api-version", VERSION);

    if (body != null) {
      builder.header("Content-Type", "application/json");
    }

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

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

    return parseJsonResponse(response.body());
  }

  static Map<String, Object> parseJsonResponse(String json) {
    Map<String, Object> result = new LinkedHashMap<>();
    if (json == null || json.isEmpty() || json.equals("{}")) {
      return result;
    }
    // Simple JSON parser for response objects
    if (json.contains("\"id\"")) {
      int start = json.indexOf("\"id\"");
      int colonIdx = json.indexOf(":", start);
      int quoteStart = json.indexOf("\"", colonIdx);
      if (quoteStart < 0) {
        int numStart = colonIdx + 1;
        while (numStart < json.length() && Character.isWhitespace(json.charAt(numStart))) {
          numStart++;
        }
        int numEnd = numStart;
        while (numEnd < json.length() && Character.isDigit(json.charAt(numEnd))) {
          numEnd++;
        }
        if (numEnd > numStart) {
          result.put("id", json.substring(numStart, numEnd));
        }
      } else {
        int quoteEnd = json.indexOf("\"", quoteStart + 1);
        if (quoteEnd > quoteStart) {
          result.put("id", json.substring(quoteStart + 1, quoteEnd));
        }
      }
    }
    if (json.contains("\"data\"")) {
      result.put("data", List.of());
    }
    if (json.contains("\"items\"")) {
      result.put("items", List.of());
    }
    return result;
  }

  public static void main(String[] args) {
    try {
      Map<String, Object> workflow = buildWorkflow();
      String workflowName = (String) workflow.get("name");
      System.out.println("Deploying \"" + workflowName + "\"…");

      State state = State.load();

      if (state.workflowId != null) {
        System.out.println("✓ workflow already provisioned (" + state.workflowId + ") — updating steps");
        Map<String, Object> updateBody = new LinkedHashMap<>();
        updateBody.put("steps", workflow.get("steps"));
        apiCall("POST", "/workflows/" + state.workflowId, updateBody);
      } else {
        boolean found = false;
        try {
          String encoded = URLEncoder.encode(workflowName, StandardCharsets.UTF_8);
          Map<String, Object> list = apiCall("GET", "/workflows?name=" + encoded, null);
          List<?> items = (List<?>) (list.containsKey("data") ? list.get("data") : list.get("items"));
          if (items != null) {
            for (Object item : items) {
              if (item instanceof Map) {
                Map<String, Object> itemMap = (Map<String, Object>) item;
                if (workflowName.equals(itemMap.get("name"))) {
                  String existingId = (String) itemMap.get("id");
                  if (existingId != null) {
                    state.workflowId = existingId;
                    state.save();
                    System.out.println("✓ workflow \"" + workflowName + "\" found in your account (" + existingId + ") — updating steps");
                    Map<String, Object> updateBody = new LinkedHashMap<>();
                    updateBody.put("steps", workflow.get("steps"));
                    apiCall("POST", "/workflows/" + existingId, updateBody);
                    found = true;
                    break;
                  }
                }
              }
            }
          }
        } catch (Exception e) {
          // lookup is best-effort; fall through to create
        }

        if (!found) {
          Map<String, Object> created = apiCall("POST", "/workflows", workflow);
          String wfId = (String) created.get("id");
          if (wfId == null && created.containsKey("workflow")) {
            Map<String, Object> wf = (Map<String, Object>) created.get("workflow");
            wfId = (String) wf.get("id");
          }
          if (wfId == null) {
            throw new RuntimeException("Could not read created workflow id from response");
          }
          state.workflowId = wfId;
          state.save();
          System.out.println("+ created workflow (" + wfId + ")");
        }
      }

      try {
        apiCall("POST", "/workflows/" + state.workflowId + "/versions", new LinkedHashMap<>());
      } 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);
    }
  }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// It deploys the "Invoice Parser" 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: invoice-parser).

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"
)

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

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

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

var (
	apiKey   string
	stateDir string
	stateFile string
	state    State
)

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

	stateDir = filepath.Join(".", ".extend")
	stateFile = filepath.Join(stateDir, "invoice-parser.json")

	// Load existing state if it exists
	if data, err := os.ReadFile(stateFile); err == nil {
		json.Unmarshal(data, &state)
	}
}

func saveState() 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{}) (map[string]interface{}, error) {
	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, API+pathName, 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()

	respData := make(map[string]interface{})
	json.NewDecoder(resp.Body).Decode(&respData)

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

	return respData, nil
}

func main() {
	workflow := Workflow{
		Name: "Invoice Parser 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{}{},
						},
					},
				},
			},
		},
	}

	fmt.Printf("Deploying \"%s\"…\n", workflow.Name)

	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, "%v\n", err)
			os.Exit(1)
		}
	} else {
		// Try to find existing workflow with same name
		query := url.QueryEscape(workflow.Name)
		list, err := apiCall("GET", fmt.Sprintf("/workflows?name=%s", query), nil)
		if err == nil {
			var items []map[string]interface{}
			if data, ok := list["data"].([]interface{}); ok {
				for _, item := range data {
					items = append(items, item.(map[string]interface{}))
				}
			} else if data, ok := list["items"].([]interface{}); ok {
				for _, item := range data {
					items = append(items, item.(map[string]interface{}))
				}
			}

			for _, item := range items {
				if name, ok := item["name"].(string); ok && name == workflow.Name {
					if id, ok := item["id"].(string); ok {
						state.WorkflowID = id
						saveState()
						fmt.Printf("✓ workflow \"%s\" found in your account (%s) — updating steps\n", workflow.Name, id)
						_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", id), map[string]interface{}{"steps": workflow.Steps})
						if err != nil {
							fmt.Fprintf(os.Stderr, "%v\n", err)
							os.Exit(1)
						}
						break
					}
				}
			}
		}

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

			var wfID string
			if id, ok := created["id"].(string); ok {
				wfID = id
			} else if wf, ok := created["workflow"].(map[string]interface{}); ok {
				if id, ok := wf["id"].(string); ok {
					wfID = id
				}
			}

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

			state.WorkflowID = wfID
			saveState()
			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 `mode: "agentic_ocr"` in parse to ensure accurate OCR across language variants, then add currency symbols and language codes to your extract schema descriptions (e.g., `"total_amount": { "description": "Total in USD, EUR, or GBP format, e.g. $1,234.56 or €1.234,56" }`). The extraction processor will normalize values based on your field descriptions.
For financial accuracy, require `confidence >= 0.85` on critical fields like `total_amount` and `vendor_name`; you can accept 0.70+ for optional fields like line-item descriptions.
Nuanced question and depends on the use case! For an agent pipeline, you'll likely just stop at Parsing, take the markdown/HTML output and feed that into your pipeline. For Key-Value extraction into JSON, you can jump straight into Extraction because there is always a Parse step beforehand
Use async parse via `parseRuns.createAndPoll()` with `mode: "agentic_ocr"` to process in parallel batches, then queue extraction jobs using your database; monitor `X-RateLimit-Remaining` headers and implement exponential backoff on 429 responses. For high volume (>1k/day), contact Extend support for rate-limit increases.
Tags
InvoicesFood ServiceLine ItemsDelivery
About this template

This template processes invoices It captures customer information, delivery details, itemized product lines with quantities and pricing, and tax calculations and turns it into clean markdown for AI agents.

Document formats
  • PDF
  • Images & Scans
Requirements
  • Long tables
  • Scanned documents

Relevant templates for Financial & Banking

  1. 01
    Driver's License ExtractorParse → Extract
    Extracts personal identification and licensing data from driver license documents.
    PDFImages & Scanswww.extend.ai/templates/driver-license-template
  2. 02
    Receipt ExtractorParse → Extract
    Extracts itemized sales, pricing, GST tax, and payment details from retail receipts.
    PDFImages & Scanswww.extend.ai/templates/receipt-parse-extract
  3. 03
    Bank Statement ExtractorParse → Extract
    Extracts account summaries, balances, deposits, NSF flags, and transaction details from bank statements.
    PDFImages & Scanswww.extend.ai/templates/bank-statement
  4. 04
    Vendor Invoice ExtractorParse → Extract
    Extracts charges, billing details, line items, totals, and due dates vendor invoices.
    PDFImages & Scanswww.extend.ai/templates/vendor-invoice
  5. 05
    Pay Stub ExtractorParse → Extract
    Extracts employee earnings, deductions, taxes, and net pay from pay stubs.
    PDFwww.extend.ai/templates/pay-stub
  6. 06
    Check ExtractorParse → Extract
    Extracts check details including payee, amount, date, and bank routing information.
    PDFImages & Scanswww.extend.ai/templates/check
  7. 07
    Wire Transfer Instructions ExtractorParse → Extract
    Extracts wire transfer procedures, contact info, and operational hours from banking guides.
    PDFWord / DOCXwww.extend.ai/templates/wire-transfer-instructions
  8. 08
    Onboarding Package ExtractorParse → Extract
    Extracts account summaries, transaction details, and balances from bank statements.
    PDFImages & Scanswww.extend.ai/templates/personal-bank-statement