Skip to main content
This example demonstrates how to build a multi-step LLM pipeline that transforms minified JavaScript into readable code. It showcases Thinkwell’s core pattern: use deterministic code for orchestration and iteration, while delegating semantic understanding to the AI agent.

What the Unminifier Does

The unminifier takes minified JavaScript (like underscore-umd-min.js) and produces readable code through five steps:
  1. Pretty-print with Prettier (no LLM needed)
  2. Convert UMD to ESM - strip the UMD boilerplate and use modern module syntax
  3. Extract functions - identify all top-level functions with minified names
  4. Analyze functions - suggest descriptive names for each function (in parallel batches)
  5. Apply renames - perform all identifier renames in a single pass
This pipeline illustrates a key insight: some transformations are purely mechanical (formatting), while others require semantic understanding (naming). Thinkwell lets you mix both approaches naturally.

The Schema Definitions

First, define the structured outputs for each LLM step using the @JSONSchema annotation:
/**
 * Result of converting a UMD module to ESM.
 * @JSONSchema
 */
export interface ModuleConversion {
  /** The converted ESM code with default export */
  code: string;
  /** The name of the main exported object/function */
  exportedName: string;
}

/**
 * Information about a function found in the code.
 * @JSONSchema
 */
export interface FunctionInfo {
  /** Current function name (may be minified) */
  name: string;
  /** Function signature including parameters */
  signature: string;
  /** Approximate line number where the function is defined */
  lineNumber: number;
}

/**
 * List of functions extracted from the code.
 * @JSONSchema
 */
export interface FunctionList {
  /** List of all top-level functions in the code */
  functions: FunctionInfo[];
}

/**
 * Analysis result for a single function.
 * @JSONSchema
 */
export interface FunctionAnalysis {
  /** The original minified function name */
  originalName: string;
  /** Suggested descriptive name (camelCase, no underscores unless conventional) */
  suggestedName: string;
  /** Brief description of what the function does */
  purpose: string;
  /** Confidence level in the suggested name */
  confidence: "high" | "medium" | "low";
}

/**
 * Batch of function analyses.
 * @JSONSchema
 */
export interface FunctionAnalysisBatch {
  /** Array of function analyses */
  analyses: FunctionAnalysis[];
}

/**
 * Result of applying renames to code.
 * @JSONSchema
 */
export interface RenamedCode {
  /** The code with all renames applied */
  code: string;
  /** Number of identifiers that were renamed */
  renameCount: number;
}
Notice how each schema serves a specific step in the pipeline:
  • ModuleConversion captures both the transformed code and metadata about what was exported
  • FunctionList uses a nested structure to return multiple items
  • FunctionAnalysis includes a confidence field with literal union types for quality signals
  • FunctionAnalysisBatch wraps multiple analyses for efficient batched processing

Step 1: Pretty-Print with Prettier

The first step is purely mechanical - no LLM required. Use Prettier to make the minified code readable:
import * as prettier from "prettier";

export async function formatCode(code: string): Promise<string> {
  return prettier.format(code, {
    parser: "babel",
    printWidth: 100,
    tabWidth: 2,
    semi: true,
    singleQuote: false,
  });
}
This demonstrates an important pattern: use traditional tools where they excel. Prettier handles formatting perfectly - there’s no need to involve an LLM.

Step 2: Convert UMD to ESM

Now we need semantic understanding. The LLM can recognize UMD boilerplate patterns and extract the module body:
const conversion = await agent
  .think(ModuleConversion.Schema)
  .text(`
    Convert this UMD module to an ESM module with a default export.
    Remove the UMD wrapper boilerplate (the IIFE that checks for exports/define/globalThis).
    Keep all the internal code intact, just change the module format.
    The code should end with a default export of the main library object.

  `)
  .code(prettyCode, "javascript")
  .run();

console.log(`Exported as: ${conversion.exportedName}`);
const esmCode = await formatCode(conversion.code);
The .code() method formats the code as a fenced code block in the prompt, making it clear to the agent where the code begins and ends.

Step 3: Extract Function List

Next, ask the agent to identify all functions that need renaming:
const functionList = await agent
  .think(FunctionList.Schema)
  .text(`
    Extract a list of all top-level function declarations and function expressions
    assigned to variables in this code. Include the function name, its signature
    (parameters), and approximate line number. Focus on functions with short
    (1-2 character) names that appear to be minified.

  `)
  .code(esmCode, "javascript")
  .run();

console.log(`Found ${functionList.functions.length} functions to analyze`);
The structured FunctionList output gives you an array you can iterate over in TypeScript - bridging the gap between natural language analysis and programmatic control flow.

Step 4: Parallel Batch Analysis

Analyzing functions one at a time would be slow. Instead, batch them and process in parallel with a concurrency limit:
import pLimit from "p-limit";
import _ from "lodash";

const renames: Map<string, string> = new Map();
const limit = pLimit(5);  // Max 5 concurrent requests
const batches = _.chunk(functionList.functions, 30);  // 30 functions per batch

const results = await Promise.all(
  batches.map((batch) =>
    limit(async () => {
      const functionListText = batch
        .map((f) => `  - "${f.name}" with signature ${f.signature}`)
        .join("\n");

      return agent
        .think(FunctionAnalysisBatch.Schema)
        .text(`
          Analyze each of the following minified functions and suggest better,
          more descriptive names.

          IMPORTANT: For each function, the 'originalName' field in your response
          must be the EXACT minified name shown in quotes below.

          Functions to analyze:

          ${functionListText}

          Here is the full code for context:

        `)
        .code(esmCode, "javascript")
        .run();
    })
  )
);

// Collect all renames
for (const batch of results) {
  for (const analysis of batch.analyses) {
    if (analysis.suggestedName !== analysis.originalName) {
      renames.set(analysis.originalName, analysis.suggestedName);
      console.log(`  ${analysis.originalName} -> ${analysis.suggestedName}`);
    }
  }
}
Key patterns here:
  • p-limit prevents overwhelming the agent with too many concurrent requests
  • _.chunk groups functions into manageable batches
  • FunctionAnalysisBatch returns multiple analyses per request, reducing round trips
  • Sending full source code as context helps the LLM understand function interdependencies

Step 5: Apply Renames

Finally, ask the agent to apply all the renames in a single pass:
const renameList = Array.from(renames.entries())
  .map(([from, to]) => `  ${from} -> ${to}`)
  .join("\n");

const renamed = await agent
  .think(RenamedCode.Schema)
  .text(`
    Apply the following renames to the code. Be careful to only rename
    the function definitions and all their usages, not unrelated identifiers
    that happen to have the same name:

    ${renameList}

    Here is the code:

  `)
  .code(esmCode, "javascript")
  .run();

const finalCode = await formatCode(renamed.code);
console.log(`Applied ${renamed.renameCount} renames`);
Doing renames in a single pass rather than incrementally avoids ordering issues (what if function a calls function b and both need renaming?).

The Complete Pipeline

Here’s how it all fits together:
import { Agent } from "thinkwell:agent";
import { CLAUDE_CODE } from "thinkwell:connectors";
import * as fs from "fs/promises";
import * as prettier from "prettier";
import pLimit from "p-limit";
import _ from "lodash";

async function main() {
  const agent = await Agent.connect(process.env.THINKWELL_AGENT_CMD ?? CLAUDE_CODE);

  try {
    const minifiedCode = await fs.readFile("underscore-umd-min.js", "utf-8");

    // Step 1: Pretty-print (no LLM)
    const prettyCode = await formatCode(minifiedCode);

    // Step 2: Convert UMD to ESM (LLM)
    const conversion = await agent
      .think(ModuleConversion.Schema)
      .text(`Convert this UMD module to ESM...`)
      .code(prettyCode, "javascript")
      .run();
    const esmCode = await formatCode(conversion.code);

    // Step 3: Extract function list (LLM)
    const functionList = await agent
      .think(FunctionList.Schema)
      .text(`Extract all top-level functions...`)
      .code(esmCode, "javascript")
      .run();

    // Step 4: Analyze functions in parallel batches (LLM)
    const renames = new Map<string, string>();
    // ... batch processing with p-limit ...

    // Step 5: Apply renames (LLM)
    const renamed = await agent
      .think(RenamedCode.Schema)
      .text(`Apply these renames...`)
      .code(esmCode, "javascript")
      .run();

    const finalCode = await formatCode(renamed.code);
    await fs.writeFile("underscore.js", finalCode, "utf-8");
  } finally {
    agent.close();
  }
}

main();

Running the Example

thinkwell examples/src/unminify.ts
Sample output:
=== Unminify Demo ===

Reading minified code...
Input: 18432 characters

Step 1: Pretty-printing with Prettier...
Pretty-printed: 892 lines

Step 2: Converting UMD wrapper to ESM...
Exported as: _

Step 3: Extracting function list...
Found 47 functions to analyze

Step 4: Analyzing functions in batches...
  Processing 2 batches...
  j -> isArrayLike
  w -> createCallback
  A -> optimizeCb
  ...

Step 5: Applying renames...
Applied 47 renames

=== Done! ===

Key Takeaways

  1. Mix LLM and traditional tools - Use Prettier for formatting, LLM for semantic understanding
  2. Sequential agent.think() calls create natural pipeline stages
  3. Parallel processing with p-limit keeps pipelines efficient without overwhelming the agent
  4. Batch schemas like FunctionAnalysisBatch reduce round trips for repeated operations
  5. The .code() method clearly delineates code blocks in prompts
  6. Send full context when the LLM needs to understand relationships between code elements