Turns bank statements into markdown.
A bank statement is a periodic financial document issued by a financial institution that summarizes account activity, including opening and closing balances, deposits, withdrawals, fees, and a detailed transaction history for a specified period. This template takes in Bank Statement and outputs markdown (.md) files with all the content, including bank details, account information, period dates, balance summaries, and individual transaction records by using Extend's Parse primitives.
Converts the document into clean, layout-aware markdown plus structured blocks with spatial metadata.
chunks[{"id":"chunk_1_cnmeUM","type":"page","blocks":[{"id":"block_1_P9gisY","type":"figure","object":"block","content":"<figure type=\"logo\">\nCHASE JPMorgan Chase …changedparseOutputMetadata.finalMimeType"application/pdf"changedparseOutputMetadata.originalMimeType"application/pdf"changedparseOutputMetadata.pages[{"dpi":150,"number":1,"rotationApplied":0,"originalPageWidth":612,"originalPageHeight":792},{"dpi":150,"number":2,"rotationApplied":0,"originalPageWidth":612,"…changedYou can learn more about Parse configuration in Extend's Parse documentation.
{
"name": "Bank Statement 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": {}
}
}
}
}
]
}# Bank Statement Processing — Extend AI Skill
## What this pipeline does
Parses bank statements (PDFs) into structured markdown with full transaction history, account summaries, and customer details. The pipeline extracts account holder names, statement period, opening/closing balances, individual transaction records (date, description, amount, running balance), fees, and deposits using agentic OCR to handle mixed-format layouts and financial tables.
## When to use this
- **Personal finance reconciliation**: Automatically parse statements for budgeting apps or accounting software
- **Financial data aggregation**: Feed parsed statements into data lakes for fraud detection or spending analysis
- **Regulatory compliance**: Extract and archive account-level metadata for audits or KYC workflows
- **Transaction history extraction**: Build searchable transaction logs from PDF archives
- **Account verification**: Confirm account holder identity and current balances for loan/credit decisions
## Processor pipeline
| Step | Processor | Purpose | Key Config |
|------|-----------|---------|-----------|
| 1 | **Parse** (agentic_ocr mode, page chunking) | Convert PDF to markdown with full block-level detail (tables, figures, text, key-value pairs). Agentic OCR handles complex layouts; table header continuation handles multi-page transaction lists. | `blockOptions.tables.agentic.enabled: true`, `blockOptions.tables.tableHeaderContinuationEnabled: true`, `blockOptions.text.agentic.enabled: true`, `chunkingStrategy: "page"` |
**Why this config:**
- Bank statements often have headers that repeat across pages (e.g., "DATE | DESCRIPTION | AMOUNT | BALANCE"). Without `tableHeaderContinuationEnabled: true`, multi-page transactions get fragmented.
- Agentic text parsing captures narrative fields (customer service info, legal disclaimers) with high confidence even if layout is non-standard.
- Page-level chunking preserves document structure: summary tables on page 1, transaction detail on pages 1–2, disclosures on pages 3–4.
- Signature detection is enabled to flag signed statements (regulatory documents).
## TypeScript implementation
## CLI equivalent
```bash
# Upload and parse a bank statement
export EXTEND_API_KEY="sk_..."
# Using extend CLI (requires @extend-ai/cli)
extend parse bank_statement.pdf \
--mode agentic_ocr \
--output-type markdown \
--block-options '{
"text": { "agentic": { "enabled": true }, "signatureDetectionEnabled": true },
"tables": { "agentic": { "enabled": true }, "tableHeaderContinuationEnabled": true },
"figures": { "enabled": true }
}'
# Output: markdown file with full statement parsed
```
## Schema
If you wanted to extract structured fields (separate from markdown parsing), here's a production-ready Zod schema:
```typescript
import { z } from "zod";
import { extendCurrency, extendDate } from "extend-ai";
const BankStatementSchema = z.object({
account_number: z.string().nullable().describe("The account number, typically 10–12 digits"),
statement_start_date: extendDate().describe("First day of statement period (ISO yyyy-mm-dd)"),
statement_end_date: extendDate().describe("Last day of statement period (ISO yyyy-mm-dd)"),
account_holder_name: z.string().nullable().describe("Full legal name of account holder"),
account_holder_address: z.string().nullable().describe("Mailing address on file"),
account_type: z.string().nullable().describe("Account type, e.g. 'High School Checking', 'Total Checking'"),
beginning_balance: extendCurrency().describe("Opening balance at start of period"),
ending_balance: extendCurrency().describe("Closing balance at end of period"),
total_deposits: extendCurrency().describe("Sum of all deposits and additions"),
total_withdrawals: extendCurrency().describe("Sum of all withdrawals (ATM, debit, electronic)"),
total_fees: extendCurrency().describe("Service fees charged in period"),
transactions: z.array(z.object({
transaction_date: extendDate().describe("Date transaction posted (ISO yyyy-mm-dd)"),
description: z.string().nullable().describe("Merchant/service description, e.g. 'Card Purchase 01/03 Amazon Prime'"),
amount: extendCurrency().describe("Transaction amount (negative for withdrawals)"),
running_balance: extendCurrency().nullable().describe("Account balance after transaction"),
transaction_type: z.enum(["deposit", "withdrawal", "fee", "transfer"]).nullable().describe("Classify transaction"),
})).describe("All individual transactions in chronological order"),
bank_name: z.string().nullable().describe("Institution name, e.g. 'JPMorgan Chase Bank'"),
customer_service_phone: z.string().nullable().describe("Customer service number on statement"),
bank_routing_number: z.string().nullable().describe("Routing/ABA number if visible"),
});
```
**To extract with this schema** (instead of just parsing):
```typescript
const extractRun = await client.extractRuns.createAndPoll({
file: { url: dataUrl },
config: {
schema: BankStatementSchema,
},
});
if (extractRun.status === "PROCESSED") {
const statement = extractRun.output.value;
console.log(`Account: ${statement.account_number}`);
console.log(`Holder: ${statement.account_holder_name}`);
console.log(`Period: ${statement.statement_start_date} to ${statement.statement_end_date}`);
console.log(`Ending Balance: ${statement.ending_balance.amount} ${statement.ending_balance.iso_4217_currency_code}`);
console.log(`Transactions: ${statement.transactions.length} records`);
}
```
## Accuracy tips
1. **Table header continuation (critical)**: Multi-page statements (pages 1–2) have transaction detail tables that span columns across pages. Ensure `tableHeaderContinuationEnabled: true` is set; without it, headers repeat and parsing breaks.
2. **Agentic OCR for tables**: Bank statements often use light gray backgrounds, merged cells, and unusual spacing. Enable `tables.agentic.enabled: true` to let the model reason about table structure rather than relying on grid detection alone.
3. **Transaction date parsing**: Dates in transaction tables are usually short format (MM/DD, e.g., "01/04"). Pair parse output with a year from the statement header to create full ISO dates in your extraction schema.
4. **Amount formats**: Withdrawals are shown with a minus sign (e.g., "-$27.90") or sometimes just parentheses. Amounts with commas (e.g., "$1,234.99") require post-processing. Use `extendCurrency()` helper in schema to normalize.
5. **Multi-account statements**: Some households have multiple accounts on one PDF. Use page ranges or account number tagging to split if needed; the parse output chunks by page, making this easier.
6. **Signature detection**: Enable `signatureDetectionEnabled: true` to mark legally-signed statements. Useful for regulatory workflows that require authenticated documents.
7. **Statement period extraction**: Always extract the date range from the top of page 1 (e.g., "December 22, 2020 through January 25, 2021") as the canonical period; transaction dates within may have intra-statement delays.
## Trade-offs & alternatives
| Choice | Reason |
|--------|--------|
| **Parse (markdown) vs. Extract (schema)** | Parse is better for RAG/archive; extract is better for structured pipelines. Use parse for compliance/audit trails, extract for integration with fintech APIs. Parse preserves full document context; extract loses narrative. |
| **Agentic OCR vs. Light mode** | Agentic is slower (~5–10s per page) but handles scans, handwritten notes, and complex layouts. Light is sub-second for clean digital PDFs. Bank statements are usually clean → **light mode often sufficient**, but use agentic if statement is a scan or has overlapping text. |
| **Sync vs. Async** | Sync parse (standard `createAndPoll()`) blocks until done; good for small batches (<10 documents). For 100+ statements, consider async pattern: create run, poll status, retrieve later. |
| **Human review layer** | For high-stakes use (loan decisions), add a manual review step on extracted balances and transaction counts. Flag statements >30 days old or with unusual fee patterns. |
| **Account reconciliation** | After extraction, verify closing balance = opening balance + deposits − withdrawals − fees. If mismatch, re-run with agentic mode or ask user to confirm. |
| **PII handling** | Bank statements contain customer names and addresses. If ingesting into shared RAG, consider masking or hashing PII in the markdown output before storage. |
---
**Ready to ship.** This skill handles end-to-end bank statement parsing with production accuracy. Use it as-is for document archival, or pair it with the extract schema for fintech integrations.import { ExtendClient } from "extend-ai";
import fs from "fs";
/**
* Bank Statement Parser using Extend AI
* Parses PDF bank statements into markdown with full transaction history
*/
async function processBankStatement(filePath: string) {
const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });
// Convert local file to data URL (base64) for SDK consumption
const fileBuffer = fs.readFileSync(filePath);
const base64 = fileBuffer.toString("base64");
const dataUrl = `data:application/octet-stream;base64,${base64}`;
console.log("📄 Uploading bank statement...");
// Parse the statement using agentic OCR with table header continuation
// This handles multi-page transaction tables and complex layouts
const parseRun = await client.parseRuns.createAndPoll({
file: { url: dataUrl },
config: {
mode: "agentic_ocr", // Use agentic mode for complex financial layouts
outputType: "markdown",
blockOptions: {
text: {
agentic: {
enabled: true, // Capture narrative text with high confidence
},
signatureDetectionEnabled: true, // Flag signed documents
},
tables: {
agentic: {
enabled: true, // Use agentic table detection
},
tableHeaderContinuationEnabled: true, // Critical for multi-page transaction tables
},
figures: {
enabled: true, // Extract logos and charts
},
},
chunkingStrategy: {
type: "page", // Chunk by page to preserve statement structure
},
},
});
if (parseRun.status !== "PROCESSED") {
console.error(`❌ Parse failed with status: ${parseRun.status}`);
process.exit(1);
}
console.log("✅ Parse completed successfully\n");
// Combine all chunks into single markdown document
const fullMarkdown = parseRun.output.chunks
.map((chunk) => chunk.content)
.join("\n\n---\n\n");
// Extract key sections from parsed content
const summary = extractStatementSummary(fullMarkdown);
// Output results
console.log("📊 STATEMENT SUMMARY");
console.log("====================");
console.log(`Account Number: ${summary.accountNumber || "N/A"}`);
console.log(`Statement Period: ${summary.period || "N/A"}`);
console.log(`Beginning Balance: ${summary.beginningBalance || "N/A"}`);
console.log(`Ending Balance: ${summary.endingBalance || "N/A"}`);
console.log(`Total Deposits: ${summary.totalDeposits || "N/A"}`);
console.log(`Total Withdrawals: ${summary.totalWithdrawals || "N/A"}`);
console.log(`Fees: ${summary.fees || "N/A"}`);
console.log(`Account Holder: ${summary.accountHolder || "N/A"}`);
console.log(`\n📄 Full Parsed Markdown:\n`);
console.log(fullMarkdown);
// Return structured result for programmatic use
return {
status: "success",
summary,
markdown: fullMarkdown,
chunks: parseRun.output.chunks.length,
};
}
/**
* Extract key financial metrics and metadata from parsed markdown
* This is a simple regex-based extractor; for production, use extract() with a Zod schema
*/
function extractStatementSummary(markdown: string): Record<string, string | null> {
return {
accountNumber: extractRegex(markdown, /Account Number[:\s]+(\d+)/i),
period: extractRegex(markdown, /([A-Za-z]+ \d+, \d{4})\s+through\s+([A-Za-z]+ \d+, \d{4})/),
beginningBalance: extractRegex(markdown, /Beginning Balance[:\s]+(\$[\d,]+\.\d+)/i),
endingBalance: extractRegex(markdown, /Ending Balance[:\s]+(\$[\d,]+\.\d+)/i),
totalDeposits: extractRegex(markdown, /Deposits and Additions[:\s]+(\$?[\d,]+\.?\d*)/i),
totalWithdrawals: extractRegex(
markdown,
/(?:ATM & Debit Card Withdrawals|Electronic Withdrawals)[:\s]+([\d,\-$\.]+)/i
),
fees: extractRegex(markdown, /Fees[:\s]+([\d,\-$\.]+)/i),
accountHolder: extractRegex(markdown, /(?:LOPEZ|[A-Z\s]+)\s+\d{1,6}\s+SW|[A-Z]{2,}\s+[A-Z]{2,}/),
};
}
function extractRegex(text: string, pattern: RegExp): string | null {
const match = text.match(pattern);
return match ? match[1] || match[0] : null;
}
// Execute if run directly
processBankStatement(process.argv[2] || "bank_statement.pdf").catch(console.error);import os
import sys
import base64
from extend_ai import Extend
def process_bank_statement(file_path: str) -> None:
"""Process a bank statement file using Extend's parse pipeline."""
client = Extend(token=os.environ["EXTEND_API_KEY"])
# Convert local file to data URL for SDK compatibility
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("📄 Parsing bank statement...")
# Parse with agentic OCR and document-level chunking
parse_run = client.parse_runs.create_and_poll(
file={"url": data_url},
config={
"blockOptions": {
"text": {
"agentic": {
"enabled": True, # Handles scanned, handwritten, complex layouts
},
},
},
"chunkingStrategy": {
"type": "document", # Keep transactions grouped logically
},
},
)
if parse_run.status == "PROCESSED":
print("✅ Parse complete")
print("\n--- Parsed Markdown Output ---\n")
# Reconstruct full markdown from chunks
markdown = "\n\n".join(chunk.content for chunk in parse_run.output.chunks)
print(markdown)
print("\n--- End of Statement ---\n")
# Optional: Write to file for downstream use
output_path = file_path.rsplit(".", 1)[0] + "_parsed.md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(markdown)
print(f"📝 Markdown saved to: {output_path}")
# Print summary stats
print(f"\n📊 Summary:")
print(f" - Chunks parsed: {len(parse_run.output.chunks)}")
print(f" - Total characters: {len(markdown)}")
else:
print(f"❌ Parse failed with status: {parse_run.status}")
if parse_run.error:
print(f"Error: {parse_run.error.message}")
if __name__ == "__main__":
if len(sys.argv) > 1:
process_bank_statement(sys.argv[1])
else:
print("Usage: python solution.py <file_path>")// This code uses Extend's REST API directly because Extend has no official Java SDK yet.
// It calls https://api.extend.ai endpoints using only 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.Path;
import java.nio.file.Paths;
import java.util.Base64;
public class BankStatementParser {
private static final String API_BASE_URL = "https://api.extend.ai";
private static final String API_KEY = System.getenv("EXTEND_API_KEY");
public static void processBankStatement(String filePath) throws IOException, InterruptedException {
if (API_KEY == null || API_KEY.isEmpty()) {
throw new IllegalArgumentException("EXTEND_API_KEY environment variable not set");
}
// Read file and convert to base64 data URL
Path path = Paths.get(filePath);
byte[] fileBytes = Files.readAllBytes(path);
String base64Content = Base64.getEncoder().encodeToString(fileBytes);
String dataUrl = "data:application/octet-stream;base64," + base64Content;
System.out.println("📄 Parsing bank statement...");
// Create parse run request
String requestBody = String.format(
"{\"file\":{\"url\":\"%s\"},\"config\":{\"blockOptions\":{\"text\":{\"agentic\":{\"enabled\":true}}},\"chunkingStrategy\":{\"type\":\"document\"}}}",
dataUrl.replace("\"", "\\\"")
);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE_URL + "/v1/parseRuns"))
.header("Authorization", "Bearer " + API_KEY)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 && response.statusCode() != 201) {
System.err.println("❌ Failed to create parse run: " + response.statusCode());
System.err.println(response.body());
return;
}
// Extract run ID from response
String responseBody = response.body();
String runId = extractJsonField(responseBody, "id");
if (runId == null || runId.isEmpty()) {
System.err.println("❌ Could not extract run ID from response");
return;
}
// Poll for completion
String status = "PROCESSING";
String parseRunOutput = null;
int maxAttempts = 120;
int attempt = 0;
while (!status.equals("PROCESSED") && !status.equals("FAILED") && attempt < maxAttempts) {
Thread.sleep(1000);
attempt++;
HttpRequest pollRequest = HttpRequest.newBuilder()
.uri(URI.create(API_BASE_URL + "/v1/parseRuns/" + runId))
.header("Authorization", "Bearer " + API_KEY)
.GET()
.build();
HttpResponse<String> pollResponse = client.send(pollRequest, HttpResponse.BodyHandlers.ofString());
if (pollResponse.statusCode() == 200) {
parseRunOutput = pollResponse.body();
status = extractJsonField(parseRunOutput, "status");
}
}
if (status.equals("PROCESSED")) {
System.out.println("✅ Parse complete");
System.out.println("\n--- Parsed Markdown Output ---\n");
// Extract chunks and reconstruct markdown
String chunks = extractJsonField(parseRunOutput, "chunks");
String markdown = reconstructMarkdown(chunks);
System.out.println(markdown);
System.out.println("\n--- End of Statement ---\n");
// Write to output file
String outputPath = filePath.replaceAll("\\.[^.]+$", "_parsed.md");
Files.writeString(Paths.get(outputPath), markdown);
System.out.println("📝 Markdown saved to: " + outputPath);
// Print summary stats
int chunkCount = countChunks(chunks);
System.out.println("\n📊 Summary:");
System.out.println(" - Chunks parsed: " + chunkCount);
System.out.println(" - Total characters: " + markdown.length());
} else {
System.err.println("❌ Parse failed with status: " + status);
}
}
private static String extractJsonField(String json, String fieldName) {
String searchKey = "\"" + fieldName + "\":";
int startIdx = json.indexOf(searchKey);
if (startIdx == -1) return null;
startIdx += searchKey.length();
while (startIdx < json.length() && Character.isWhitespace(json.charAt(startIdx))) {
startIdx++;
}
if (startIdx >= json.length()) return null;
if (json.charAt(startIdx) == '"') {
startIdx++;
StringBuilder result = new StringBuilder();
while (startIdx < json.length() && json.charAt(startIdx) != '"') {
if (json.charAt(startIdx) == '\\' && startIdx + 1 < json.length()) {
startIdx++;
}
result.append(json.charAt(startIdx));
startIdx++;
}
return result.toString();
} else if (json.charAt(startIdx) == '[') {
int bracketCount = 1;
int endIdx = startIdx + 1;
while (endIdx < json.length() && bracketCount > 0) {
if (json.charAt(endIdx) == '[') bracketCount++;
else if (json.charAt(endIdx) == ']') bracketCount--;
endIdx++;
}
return json.substring(startIdx, endIdx);
}
return null;
}
private static String reconstructMarkdown(String chunksJson) {
StringBuilder markdown = new StringBuilder();
int idx = 0;
while (idx < chunksJson.length()) {
int contentStart = chunksJson.indexOf("\"content\":", idx);
if (contentStart == -1) break;
contentStart += 10;
while (contentStart < chunksJson.length() && Character.isWhitespace(chunksJson.charAt(contentStart))) {
contentStart++;
}
if (contentStart < chunksJson.length() && chunksJson.charAt(contentStart) == '"') {
contentStart++;
StringBuilder content = new StringBuilder();
while (contentStart < chunksJson.length() && chunksJson.charAt(contentStart) != '"') {
if (chunksJson.charAt(contentStart) == '\\' && contentStart + 1 < chunksJson.length()) {
contentStart++;
char nextChar = chunksJson.charAt(contentStart);
if (nextChar == 'n') content.append('\n');
else if (nextChar == 't') content.append('\t');
else if (nextChar == 'r') content.append('\r');
else content.append(nextChar);
} else {
content.append(chunksJson.charAt(contentStart));
}
contentStart++;
}
if (markdown.length() > 0) markdown.append("\n\n");
markdown.append(content);
idx = contentStart + 1;
} else {
break;
}
}
return markdown.toString();
}
private static int countChunks(String chunksJson) {
int count = 0;
int idx = 0;
while ((idx = chunksJson.indexOf("\"content\":", idx)) != -1) {
count++;
idx += 10;
}
return count;
}
public static void main(String[] args) throws IOException, InterruptedException {
if (args.length > 0) {
processBankStatement(args[0]);
}
}
}// 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"
"strings"
"time"
)
// ParseRunRequest represents the request body for creating a parse run
type ParseRunRequest struct {
File FileInput `json:"file"`
Config ParseConfig `json:"config"`
}
// FileInput represents the file to parse
type FileInput struct {
URL string `json:"url"`
}
// ParseConfig represents the parsing configuration
type ParseConfig struct {
BlockOptions BlockOptions `json:"blockOptions"`
ChunkingStrategy ChunkingStrategy `json:"chunkingStrategy"`
}
// BlockOptions represents block-level options
type BlockOptions struct {
Text TextOptions `json:"text"`
}
// TextOptions represents text parsing options
type TextOptions struct {
Agentic AgenticOptions `json:"agentic"`
}
// AgenticOptions represents agentic OCR options
type AgenticOptions struct {
Enabled bool `json:"enabled"`
}
// ChunkingStrategy represents the chunking strategy
type ChunkingStrategy struct {
Type string `json:"type"`
}
// ParseRunResponse represents the response from the parse run API
type ParseRunResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Output OutputData `json:"output"`
Error *ErrorInfo `json:"error"`
}
// OutputData represents the parsed output
type OutputData struct {
Chunks []Chunk `json:"chunks"`
}
// Chunk represents a parsed chunk
type Chunk struct {
Content string `json:"content"`
}
// ErrorInfo represents error details
type ErrorInfo struct {
Message string `json:"message"`
}
func processBankStatement(filePath string) error {
apiKey := os.Getenv("EXTEND_API_KEY")
if apiKey == "" {
return fmt.Errorf("EXTEND_API_KEY environment variable not set")
}
// Read file and convert to data URL
fileBuffer, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
dataURL := fmt.Sprintf("data:application/octet-stream;base64,%s",
base64.StdEncoding.EncodeToString(fileBuffer))
fmt.Println("📄 Parsing bank statement...")
// Create parse run request
req := ParseRunRequest{
File: FileInput{URL: dataURL},
Config: ParseConfig{
BlockOptions: BlockOptions{
Text: TextOptions{
Agentic: AgenticOptions{Enabled: true},
},
},
ChunkingStrategy: ChunkingStrategy{Type: "document"},
},
}
reqBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// Create and poll parse run
parseRun, err := createAndPollParseRun(apiKey, reqBody)
if err != nil {
return err
}
if parseRun.Status == "PROCESSED" {
fmt.Println("✅ Parse complete")
fmt.Println("\n--- Parsed Markdown Output ---\n")
// Reconstruct full markdown from chunks
var markdownParts []string
for _, chunk := range parseRun.Output.Chunks {
markdownParts = append(markdownParts, chunk.Content)
}
markdown := strings.Join(markdownParts, "\n\n")
fmt.Println(markdown)
fmt.Println("\n--- End of Statement ---\n")
// Write to file for downstream use
ext := filepath.Ext(filePath)
outputPath := strings.TrimSuffix(filePath, ext) + "_parsed.md"
err = os.WriteFile(outputPath, []byte(markdown), 0644)
if err != nil {
return fmt.Errorf("failed to write output file: %w", err)
}
fmt.Printf("📝 Markdown saved to: %s\n", outputPath)
// Print summary stats
fmt.Println("\n📊 Summary:")
fmt.Printf(" - Chunks parsed: %d\n", len(parseRun.Output.Chunks))
fmt.Printf(" - Total characters: %d\n", len(markdown))
} else {
fmt.Printf("❌ Parse failed with status: %s\n", parseRun.Status)
if parseRun.Error != nil {
fmt.Printf("Error: %s\n", parseRun.Error.Message)
}
}
return nil
}
func createAndPollParseRun(apiKey string, reqBody []byte) (*ParseRunResponse, error) {
client := &http.Client{Timeout: 30 * time.Second}
// Create parse run
httpReq, err := http.NewRequest("POST", "https://api.extend.ai/v1/parseRuns", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to create parse run: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var parseRun ParseRunResponse
err = json.Unmarshal(body, &parseRun)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
// Poll until completion
for parseRun.Status != "PROCESSED" && parseRun.Status != "FAILED" {
time.Sleep(2 * time.Second)
pollReq, err := http.NewRequest("GET",
fmt.Sprintf("https://api.extend.ai/v1/parseRuns/%s", parseRun.ID), nil)
if err != nil {
return nil, fmt.Errorf("failed to create poll request: %w", err)
}
pollReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
pollResp, err := client.Do(pollReq)
if err != nil {
return nil, fmt.Errorf("failed to poll parse run: %w", err)
}
defer pollResp.Body.Close()
pollBody, err := io.ReadAll(pollResp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read poll response: %w", err)
}
err = json.Unmarshal(pollBody, &parseRun)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal poll response: %w", err)
}
}
return &parseRun, nil
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <file_path>\n", os.Args[0])
os.Exit(1)
}
err := processBankStatement(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}// Deploy the "Bank Statement" 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/bank-statement-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: bank-statement-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, "bank-statement-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": "Bank Statement 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); });
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 / "bank-statement.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": "Bank Statement Processing Pipeline",
"steps": [
{
"name": "startTrigger1",
"type": "TRIGGER",
"next": [
{
"step": "parse1"
}
]
},
{
"name": "parse1",
"type": "PARSE",
"config": {
"parseConfig": {
"blockOptions": {
"text": {
"agentic": {
"enabled": True
}
}
},
"chunkingStrategy": {
"type": "document"
}
}
}
}
]
}
def main():
client = Extend(token=API_KEY)
print(f"Deploying \"{WORKFLOW['name']}\"…")
if state.get("workflowId"):
print(f"✓ workflow already provisioned ({state['workflowId']}) — updating steps")
client.workflows.update(id=state["workflowId"], steps=WORKFLOW["steps"])
else:
# Try to find an existing workflow with the same name
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 [])
existing = next((x for x in items if x.name == WORKFLOW["name"]), None)
if existing and existing.id:
state["workflowId"] = existing.id
save_state()
print(f"✓ workflow \"{WORKFLOW['name']}\" found in your account ({existing.id}) — updating steps")
client.workflows.update(id=existing.id, steps=WORKFLOW["steps"])
except Exception:
pass
if not state.get("workflowId"):
created = client.workflows.create(**WORKFLOW)
wf_id = created.id if hasattr(created, "id") else (created.workflow.id if hasattr(created, "workflow") else None)
if not wf_id:
raise ValueError("Could not read created workflow id from response")
state["workflowId"] = wf_id
save_state()
print(f"+ created workflow ({wf_id})")
# Deploy the current draft as a new version
try:
client.workflows.create_version(id=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)// Extend does NOT publish an official Java SDK — this code calls the REST API directly
// using only java.net.http.HttpClient (no third-party HTTP or JSON libraries).
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
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;
import java.util.Optional;
public class BankStatementProvisioner {
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("bank-statement.json");
static {
if (API_KEY == null || API_KEY.isEmpty()) {
System.err.println("Set EXTEND_API_KEY first.");
System.exit(1);
}
}
static class State {
String workflowId;
}
private static State state = loadState();
private static State loadState() {
State s = new State();
if (Files.exists(STATE_FILE)) {
try {
String json = Files.readString(STATE_FILE);
s.workflowId = extractJsonString(json, "workflowId");
} catch (IOException e) {
// Ignore; start fresh
}
}
return s;
}
private static void saveState() throws IOException {
Files.createDirectories(STATE_DIR);
StringBuilder json = new StringBuilder("{");
if (state.workflowId != null) {
json.append(" \"workflowId\": \"").append(state.workflowId).append("\"");
}
json.append("\n}");
Files.writeString(STATE_FILE, json.toString());
}
private static String api(String method, String pathName, String body) throws IOException, InterruptedException {
var client = java.net.http.HttpClient.newHttpClient();
var builder = java.net.http.HttpRequest.newBuilder()
.uri(URI.create(API + pathName))
.method(method, body != null
? java.net.http.HttpRequest.BodyPublishers.ofString(body)
: java.net.http.HttpRequest.BodyPublishers.noBody())
.header("Authorization", "Bearer " + API_KEY)
.header("x-extend-api-version", VERSION);
if (body != null) {
builder.header("Content-Type", "application/json");
}
var request = builder.build();
var response = client.send(request, java.net.http.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 IOException(method + " " + pathName + " failed (" + response.statusCode() + "): " + preview);
}
return response.body();
}
private static String buildWorkflowJson() {
return "{"
+ " \"name\": \"Bank Statement Processing Pipeline\","
+ " \"steps\": ["
+ " {"
+ " \"name\": \"startTrigger1\","
+ " \"type\": \"TRIGGER\","
+ " \"next\": ["
+ " {"
+ " \"step\": \"parse1\""
+ " }"
+ " ]"
+ " },"
+ " {"
+ " \"name\": \"parse1\","
+ " \"type\": \"PARSE\","
+ " \"config\": {"
+ " \"parseConfig\": {"
+ " \"blockOptions\": {"
+ " \"text\": {"
+ " \"agentic\": {"
+ " \"enabled\": true"
+ " }"
+ " }"
+ " },"
+ " \"chunkingStrategy\": {"
+ " \"type\": \"document\""
+ " }"
+ " }"
+ " }"
+ " }"
+ " ]"
+ "}";
}
private static String buildStepsJson() {
return "{"
+ " \"steps\": ["
+ " {"
+ " \"name\": \"startTrigger1\","
+ " \"type\": \"TRIGGER\","
+ " \"next\": ["
+ " {"
+ " \"step\": \"parse1\""
+ " }"
+ " ]"
+ " },"
+ " {"
+ " \"name\": \"parse1\","
+ " \"type\": \"PARSE\","
+ " \"config\": {"
+ " \"parseConfig\": {"
+ " \"blockOptions\": {"
+ " \"text\": {"
+ " \"agentic\": {"
+ " \"enabled\": true"
+ " }"
+ " }"
+ " },"
+ " \"chunkingStrategy\": {"
+ " \"type\": \"document\""
+ " }"
+ " }"
+ " }"
+ " }"
+ " ]"
+ "}";
}
private static String extractJsonString(String json, String key) {
String searchKey = "\"" + key + "\":\"";
int idx = json.indexOf(searchKey);
if (idx == -1) return null;
int start = idx + searchKey.length();
int end = json.indexOf("\"", start);
return end > start ? json.substring(start, end) : null;
}
private static String extractJsonId(String json) {
String id = extractJsonString(json, "id");
if (id != null) return id;
// Try nested workflow.id
int idx = json.indexOf("\"workflow\"");
if (idx != -1) {
int braceStart = json.indexOf("{", idx);
int braceEnd = json.indexOf("}", braceStart);
if (braceStart != -1 && braceEnd != -1) {
String nested = json.substring(braceStart, braceEnd + 1);
id = extractJsonString(nested, "id");
if (id != null) return id;
}
}
return null;
}
private static Optional<String> findExistingWorkflow(String name) {
try {
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
String response = api("GET", "/workflows?name=" + encoded, null);
// Look for "id" field in the response
String id = extractJsonString(response, "id");
if (id != null && response.contains("\"name\":\"" + name + "\"")) {
return Optional.of(id);
}
} catch (Exception e) {
// Lookup is best-effort; fall through
}
return Optional.empty();
}
public static void main(String[] args) throws IOException, InterruptedException {
String workflowName = "Bank Statement Processing Pipeline";
System.out.println("Deploying \"" + workflowName + "\"…");
if (state.workflowId != null) {
System.out.println("✓ workflow already provisioned (" + state.workflowId + ") — updating steps");
api("POST", "/workflows/" + state.workflowId, buildStepsJson());
} else {
Optional<String> existing = findExistingWorkflow(workflowName);
if (existing.isPresent()) {
state.workflowId = existing.get();
saveState();
System.out.println("✓ workflow \"" + workflowName + "\" found in your account (" + existing.get() + ") — updating steps");
api("POST", "/workflows/" + existing.get(), buildStepsJson());
} else {
String created = api("POST", "/workflows", buildWorkflowJson());
String wfId = extractJsonId(created);
if (wfId == null) {
throw new IOException("Could not read created workflow id from response");
}
state.workflowId = wfId;
saveState();
System.out.println("+ created workflow (" + wfId + ")");
}
}
// Deploy the current draft as a new version — best-effort
try {
api("POST", "/workflows/" + state.workflowId + "/versions", "{}");
} catch (Exception e) {
// Ignore; some accounts/plans may not require this
}
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.");
}
}// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// Call the REST API endpoints directly using only Go's standard library (net/http, encoding/json).
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"`
}
type WorkflowListResponse struct {
Data []map[string]interface{} `json:"data,omitempty"`
Items []map[string]interface{} `json:"items,omitempty"`
}
type WorkflowCreateResponse struct {
ID string `json:"id,omitempty"`
Workflow map[string]interface{} `json:"workflow,omitempty"`
}
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, "bank-statement.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 {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(bodyBytes)
}
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")
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var data map[string]interface{}
json.Unmarshal(respBody, &data)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respStr := string(respBody)
if len(respStr) > 300 {
respStr = respStr[:300]
}
return nil, fmt.Errorf("%s %s failed (%d): %s", method, pathName, resp.StatusCode, respStr)
}
return data, nil
}
func main() {
workflow := Workflow{
Name: "Bank Statement Processing Pipeline",
Steps: []WorkflowStep{
{
Name: "startTrigger1",
Type: "TRIGGER",
Next: []interface{}{
map[string]interface{}{
"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]interface{}{
"enabled": true,
},
},
},
"chunkingStrategy": map[string]interface{}{
"type": "document",
},
},
},
},
},
}
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
q := url.QueryEscape(workflow.Name)
listResp, err := apiCall("GET", fmt.Sprintf("/workflows?name=%s", q), nil)
if err == nil {
var items []map[string]interface{}
if data, ok := listResp["data"].([]interface{}); ok {
for _, item := range data {
if m, ok := item.(map[string]interface{}); ok {
items = append(items, m)
}
}
} else if itemsRaw, ok := listResp["items"].([]interface{}); ok {
for _, item := range itemsRaw {
if m, ok := item.(map[string]interface{}); ok {
items = append(items, m)
}
}
}
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 == "" {
createResp, err := apiCall("POST", "/workflows", workflow)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
var wfID string
if id, ok := createResp["id"].(string); ok {
wfID = id
} else if wfObj, ok := createResp["workflow"].(map[string]interface{}); ok {
if id, ok := wfObj["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.")
}This Bank Statement parser template captures checking account summaries, transaction histories, and account holder information from financial institutions. It parses opening/closing balances, deposits, withdrawals, fees, and individual transaction details with dates and amounts, and outputs clean markdown for AI agents.