JavaScript Agents Development Guide

→ See also: Agent Best Practices for reusable helpers, common patterns, and critical preservation rules

Overview

DMtools JavaScript agents run via GraalJS (polyglot JavaScript execution in JVM) and provide direct access to all 67+ MCP tools as native JavaScript functions. Agents are used for preprocessing data, post-processing results, and orchestrating workflows.

Agent Structure

Basic Agent Template

/**
 * Agent Name: Process Jira Tickets
 * Description: Preprocesses Jira tickets before AI analysis
 * MCP Tools Used: jira_get_ticket, jira_update_labels, jira_post_comment
 */
function action(params) {
    try {
        // 1. Input validation
        if (!params.ticketKey) {
            return {
                success: false,
                error: "Missing required parameter: ticketKey"
            };
        }

        // 2. Use MCP tools directly as functions
        const ticket = jira_get_ticket(params.ticketKey);

        // 3. Process data
        const processed = {
            key: ticket.key,
            summary: ticket.fields.summary,
            priority: ticket.fields.priority.name,
            status: ticket.fields.status.name
        };

        // 4. Update ticket if needed
        if (processed.priority === "High" && !ticket.fields.labels.includes("urgent")) {
            jira_update_labels(ticket.key, ticket.fields.labels.concat(["urgent"]).join(","));
            jira_post_comment(ticket.key, "Marked as urgent due to high priority");
        }

        // 5. Return results
        return {
            success: true,
            data: processed
        };

    } catch (error) {
        return {
            success: false,
            error: error.toString()
        };
    }
}

// Entry point - DMtools calls this
action(params);

The params Object

Every JS agent receives a single params argument with the following structure:

{
    ticket: {               // Current ticket being processed
        key: "PROJ-123",
        fields: { summary, description, status, labels, priority, ... }
    },
    jobParams: {            // Full serialized job config (all params from JSON)
        inputJql: "...",
        initiator: "user@company.com",
        customParams: { ... },  // ← your custom data (see below)
        // ... all other params fields
    },
    response: "...",        // AI response string (null in preJSAction)
    initiator: "user@company.com",
    inputFolderPath: "/abs/path/input/PROJ-123"  // preCliJSAction only
}

Accessing customParams

Pass arbitrary data from the JSON config to JS agents via customParams:

JSON config:

{
  "name": "Teammate",
  "params": {
    "inputJql": "key = PROJ-123",
    "preJSAction": "agents/js/triggerWorkflow.js",
    "customParams": {
      "workflowId": "rework.yml",
      "targetBranch": "main",
      "flags": { "dryRun": false }
    }
  }
}

JS agent:

function action(params) {
    const custom = params.jobParams.customParams;

    const workflowId   = custom.workflowId;       // "rework.yml"
    const targetBranch = custom.targetBranch;     // "main"
    const dryRun       = custom.flags.dryRun;     // false

    if (!dryRun) {
        github_trigger_workflow(
            "my-org",
            "my-repo",
            workflowId,
            JSON.stringify({ user_request: params.ticket.key }),
            targetBranch
        );
    }

    return { success: true };
}

MCP Tools Access

All 67+ MCP tools are available as direct JavaScript functions:

Jira Tools (35+)

// Get ticket
const ticket = jira_get_ticket("PROJ-123");

// Search tickets
const results = jira_search_by_jql("project = PROJ AND status = Open");

// Create ticket
const newTicket = jira_create_ticket_basic(
    "PROJ",           // project
    "Story",          // type
    "New Feature",    // summary
    "Description..."  // description
);

// Update ticket
jira_update_ticket("PROJ-123", {
    summary: "Updated summary",
    priority: { name: "High" }
});

// Add comment
jira_post_comment("PROJ-123", "Processing complete");

// Transition ticket
jira_transition_ticket("PROJ-123", "In Progress");

// Bulk operations
const stories = [
    { summary: "Story 1", description: "..." },
    { summary: "Story 2", description: "..." }
];
jira_bulk_create_stories("PROJ", JSON.stringify(stories));

Azure DevOps Tools (23+)

// Get work item
const workItem = ado_get_work_item(12345);

// Update work item
ado_update_work_item(12345, {
    "System.Title": "Updated title",
    "System.State": "Active"
});

// Add comment
ado_add_comment(12345, "Review complete");

// Move to state
ado_move_to_state(12345, "Resolved");

// Assign work item
ado_assign_work_item(12345, "user@company.com");

AI Tools (10+)

// Gemini
const response = gemini_ai_chat("Analyze this requirement: ...");

// OpenAI
const analysis = openai_ai_chat("Generate test cases for: ...");

// Claude via Bedrock
const review = bedrock_ai_chat("Review this code: ...");

// Ollama (local)
const summary = ollama_ai_chat("Summarize: ...");

// DIAL Enterprise
const result = dial_ai_chat("Process this request: ...");

File Operations (4)

// Read file
const content = file_read("/path/to/file.txt");

// Write file
file_write("/path/to/output.json", JSON.stringify(data, null, 2));

// Validate JSON
const isValid = file_validate_json(jsonString);
if (!isValid.valid) {
    console.error("JSON error:", isValid.error);
}

// List files
const files = file_list("/path/to/directory");

Figma Tools (12+)

// Get design layers
const layers = figma_get_layers("file-key");

// Extract icons
const icons = figma_get_icons("file-key");

// Download image
figma_download_image_as_file("file-key", "node-id", "/path/to/save.png");

// Get components
const components = figma_get_library_components("file-key");

Confluence Tools (13+)

// Search content
const pages = confluence_search_content_by_text("search term");

// Get page by title
const page = confluence_content_by_title("Page Title", "SPACE");

// Create page
confluence_create_page("SPACE", "New Page", "<p>Content</p>", "parent-id");

// Update page
confluence_update_page("page-id", "Updated Title", "<p>New content</p>");

Real-World Examples

Example 1: WIP Label Checker

From agents/js/checkWipLabel.js:

/**
 * Check if ticket has WIP label
 * Used to prevent processing of work-in-progress items
 */
function action(params) {
    const ticket = params.ticket;
    const labels = ticket.fields.labels || [];

    const hasWipLabel = labels.some(label =>
        label.toLowerCase() === 'wip' ||
        label.toLowerCase() === 'work-in-progress'
    );

    if (hasWipLabel) {
        console.log(`Ticket ${ticket.key} has WIP label, skipping processing`);
        return {
            skip: true,
            reason: "WIP label present"
        };
    }

    return {
        skip: false,
        ticket: ticket
    };
}

Example 2: Xray Precondition Handler

From agents/js/preprocessXrayTestCases.js:

/**
 * Handle temporary precondition IDs in test cases
 * Creates actual preconditions in Jira and replaces temp IDs
 */
function action(params) {
    const newTestCases = params.newTestCases || [];
    const projectCode = params.ticket.key.split("-")[0];

    for (const testCase of newTestCases) {
        // Check for temporary precondition IDs
        if (testCase.customFields?.preconditions) {
            const tempPreconditions = testCase.customFields.preconditions;
            const realPreconditions = [];

            for (const precondition of tempPreconditions) {
                if (precondition.startsWith("@precondition-")) {
                    // Create actual precondition in Jira
                    const newPrecondition = jira_xray_create_precondition(
                        projectCode,
                        `Precondition for ${testCase.summary}`,
                        precondition.replace("@precondition-", "")
                    );
                    realPreconditions.push(newPrecondition.key);
                } else {
                    // Use existing precondition
                    realPreconditions.push(precondition);
                }
            }

            // Replace with real precondition keys
            testCase.customFields.preconditions = realPreconditions;
        }
    }

    return newTestCases;
}

Example 3: Multi-Tool Workflow

/**
 * Complete workflow: Analyze story, generate tests, update tickets
 */
function action(params) {
    const results = {
        analyzed: 0,
        testsCreated: 0,
        errors: []
    };

    try {
        // 1. Search for stories needing test cases
        const stories = jira_search_by_jql(
            "project = PROJ AND type = Story AND 'Test Cases' is EMPTY"
        );

        for (const story of stories) {
            try {
                // 2. Get full story details
                const fullStory = jira_get_ticket(story.key);

                // 3. Use AI to generate test cases
                const testCasesJson = gemini_ai_chat(`
                    Generate comprehensive test cases for this user story:
                    Title: ${fullStory.fields.summary}
                    Description: ${fullStory.fields.description}

                    Return as JSON array with fields: title, steps, expectedResult
                `);

                const testCases = JSON.parse(testCasesJson);

                // 4. Create test cases in Xray
                for (const tc of testCases) {
                    const test = jira_xray_create_test(
                        "PROJ",
                        tc.title,
                        tc.steps
                    );

                    // 5. Link test to story
                    jira_link_issues(test.key, story.key, "Tests");

                    results.testsCreated++;
                }

                // 6. Update story with completion comment
                jira_post_comment(story.key,
                    `✅ Generated ${testCases.length} test cases automatically`
                );

                // 7. Add label
                jira_update_labels(story.key, "tests-generated");

                results.analyzed++;

            } catch (error) {
                results.errors.push({
                    ticket: story.key,
                    error: error.toString()
                });
            }
        }

        return results;

    } catch (error) {
        return {
            success: false,
            error: error.toString()
        };
    }
}

Common Patterns

Pattern 1: Error Handling

function action(params) {
    try {
        // Validate inputs
        if (!params.required) {
            throw new Error("Missing required parameter");
        }

        // Main logic with try-catch for each operation
        let result;
        try {
            result = jira_get_ticket(params.ticketKey);
        } catch (e) {
            console.error("Failed to get ticket:", e);
            return { success: false, error: "Ticket not found" };
        }

        return { success: true, data: result };

    } catch (error) {
        // Global error handler
        console.error("Agent error:", error);
        return {
            success: false,
            error: error.toString(),
            stack: error.stack
        };
    }
}

Pattern 2: Batch Processing

function action(params) {
    const batchSize = 10;
    const items = params.items || [];
    const results = [];

    // Process in batches to avoid rate limits
    for (let i = 0; i < items.length; i += batchSize) {
        const batch = items.slice(i, i + batchSize);

        for (const item of batch) {
            try {
                const result = processItem(item);
                results.push(result);
            } catch (e) {
                console.error(`Failed to process ${item.id}:`, e);
                results.push({ id: item.id, error: e.toString() });
            }
        }

        // Rate limit protection
        if (i + batchSize < items.length) {
            sleep(1000); // 1 second delay between batches
        }
    }

    return results;
}

Pattern 3: Data Transformation

function action(params) {
    const ticket = jira_get_ticket(params.ticketKey);

    // Transform Jira data to custom format
    const transformed = {
        id: ticket.key,
        title: ticket.fields.summary,
        description: ticket.fields.description || "No description",
        metadata: {
            priority: ticket.fields.priority?.name || "Medium",
            status: ticket.fields.status?.name,
            assignee: ticket.fields.assignee?.displayName || "Unassigned",
            created: ticket.fields.created,
            updated: ticket.fields.updated
        },
        customFields: {}
    };

    // Map custom fields
    const fieldMapping = params.fieldMapping || {};
    for (const [jiraField, ourField] of Object.entries(fieldMapping)) {
        if (ticket.fields[jiraField]) {
            transformed.customFields[ourField] = ticket.fields[jiraField];
        }
    }

    return transformed;
}

Testing and Debugging Agents

JSRunner executes one GraalJS script with DMtools context for isolated automation, debugging, and JS agent testing.

# Run agent with no params (useful for scripts that read their own config)
dmtools run agents/js/myScript.js

# Run with inline JSON params — becomes params.jobParams inside JS
dmtools run agents/js/myScript.js '{"ticketKey": "PROJ-123", "dryRun": true}'

# Run with params from a file (useful for complex inputs)
dmtools run agents/js/myScript.js "$(cat agents/js/test-params.json)"

How params are mapped: the JSON object you pass becomes params.jobParams inside the agent:

// dmtools run agents/js/myScript.js '{"ticketKey":"PROJ-123","dryRun":true}'

function action(params) {
    const key    = params.jobParams.ticketKey;  // "PROJ-123"
    const dryRun = params.jobParams.dryRun;     // true
    // params.ticket   → null (no ticket context when running via JSRunner)
    // params.response → null (no AI response)
}

Simulating a postprocess action (with ticket + AI response)

Use the full JSON config form when you need to pass ticket or response:

// agents/test/test-postprocess.json
{
  "name": "JSRunner",
  "params": {
    "jsPath": "agents/js/myPostAction.js",
    "jobParams": { "dryRun": true },
    "ticket": { "key": "PROJ-123", "fields": { "summary": "My story" } },
    "response": "[{\"summary\":\"Test case 1\",\"priority\":\"High\"}]"
  }
}
dmtools run agents/test/test-postprocess.json

Dry-run pattern

Add a dryRun guard in your agent to test logic without side effects:

function action(params) {
    const dryRun = params.jobParams?.dryRun || false;

    const ticket = jira_get_ticket("PROJ-123");
    const comment = "Processing complete ✅";

    if (dryRun) {
        console.log("[DRY RUN] Would post comment:", comment);
        console.log("[DRY RUN] Ticket:", JSON.stringify(ticket, null, 2));
        return { dryRun: true, wouldPost: comment };
    }

    jira_post_comment(ticket.key, comment);
    return { success: true };
}
# Safe to run — makes no writes
dmtools run agents/js/myScript.js '{"dryRun": true}'

# Real run
dmtools run agents/js/myScript.js '{}'

Console Output

console.log/warn/error output appears directly in the DMtools terminal — no special setup needed:

function action(params) {
    console.log("Starting with:", JSON.stringify(params.jobParams));

    const ticket = jira_get_ticket("PROJ-123");
    console.log("Ticket summary:", ticket.fields.summary);

    console.warn("Watch out for this edge case");
    console.error("This is an error message");

    return { done: true };
}

Verbose MCP Tool-Call Logging (opt-in)

By default, DMtools does not log the raw arguments of every MCP tool call your agent makes, or the “Exposed MCP tool X to JavaScript” registration listing at startup. This is intentional: tool args can include large payloads (e.g. a file_write call containing an entire file’s contents, or a Jenkins console log), and logging them unconditionally can turn a CI job’s log into a multi-megabyte wall of text.

If you need to see exactly what args are passed to each MCP tool call while debugging an agent locally, opt in with an environment variable:

DMTOOLS_JS_LOG_TOOL_CALLS=true dmtools run agents/js/myScript.js '{}'

This enables, for the duration of that run:

  • Calling tool <name> with args: <json> before every MCP tool invocation
  • Exposed MCP tool <name> to JavaScript for each tool registered at JS context startup

Leave this unset (or false) in CI/production pipelines to keep logs readable.

Debug Mode (opt-in verbose output)

Use a debug param flag to enable verbose output without cluttering production logs:

function action(params) {
    const debug = params.jobParams?.debug || false;

    if (debug) {
        console.log("=== DEBUG: params ===");
        console.log(JSON.stringify(params, null, 2));
    }

    const tickets = jira_search_by_jql("project = PROJ AND status = Open");

    if (debug) {
        console.log("=== DEBUG: found", tickets.length, "tickets ===");
        tickets.forEach(t => console.log(" -", t.key, t.fields.summary));
    }

    // ... rest of logic
}
# Verbose run
dmtools run agents/js/myScript.js '{"debug": true}'

# Normal run
dmtools run agents/js/myScript.js '{}'

Test Locally with Node.js (no DMtools needed)

Useful for unit-testing pure logic (transforms, JSON manipulation) without any API calls:

cat > test_agent.js << 'EOF'
// Stub MCP functions — return fixture data, no real API calls
function jira_get_ticket(key) {
    return { key: key, fields: { summary: "My Story", labels: [], priority: { name: "High" } } };
}
function jira_post_comment(key, text) {
    console.log("[STUB] jira_post_comment(", key, ",", text, ")");
}
function jira_update_labels(key, labels) {
    console.log("[STUB] jira_update_labels(", key, ",", labels, ")");
}

// Paste your agent function here (or require it if using Node modules)
function action(params) {
    const ticket = jira_get_ticket(params.ticketKey);
    if (ticket.fields.priority.name === "High") {
        jira_update_labels(ticket.key, "urgent");
        jira_post_comment(ticket.key, "Marked urgent");
    }
    return { success: true, key: ticket.key };
}

// Run it
const result = action({ ticketKey: "PROJ-123" });
console.log("Result:", JSON.stringify(result, null, 2));
EOF

node test_agent.js

Node.js runs a different engine than GraalJS. This is only suitable for testing pure business logic. For MCP tool calls and real integrations, always use JSRunner (dmtools run).


Choosing the Right Testing Approach

SituationRecommended approach
Testing logic with real Jira/Confluence datadmtools run agents/js/myScript.js '{"ticketKey":"PROJ-123"}'
Testing a post-action with ticket + AI responseFull JSRunner JSON config with ticket and response fields
Safe end-to-end test without side effectsAdd dryRun: true param + guard in agent
Testing pure JS logic (no API calls)node test_agent.js with stubbed MCP functions
Debugging unexpected behaviourAdd console.log + run with debug: true param

Performance Tips

1. Minimize API Calls

// ❌ Bad: Multiple API calls
for (const key of ticketKeys) {
    const ticket = jira_get_ticket(key);
    // Process ticket
}

// ✅ Good: Batch API call
const tickets = jira_get_tickets_by_ids(ticketKeys.join(","));
for (const ticket of tickets) {
    // Process ticket
}

2. Cache Results

const cache = {};

function getCachedTicket(key) {
    if (!cache[key]) {
        cache[key] = jira_get_ticket(key);
    }
    return cache[key];
}

3. Parallel Processing

function action(params) {
    const promises = params.ticketKeys.map(key =>
        Promise.resolve(jira_get_ticket(key))
    );

    Promise.all(promises).then(tickets => {
        // Process all tickets
        return tickets;
    });
}

Available Global Functions

Utility Functions

// Sleep for milliseconds
sleep(1000);  // Sleep 1 second

// Parse JSON safely
const data = JSON.parse(jsonString);

// Stringify with formatting
const json = JSON.stringify(object, null, 2);

// Date operations
const now = new Date();
const iso = now.toISOString();

Console Functions

console.log("Info message");
console.error("Error message");
console.warn("Warning message");
console.debug("Debug message");
console.info("Info message");

Integration with DMtools

Using in Teammate Configuration

{
  "name": "Teammate",
  "params": {
    "preprocessJSAction": "agents/js/validateStory.js",
    "postprocessJSAction": "agents/js/updateTickets.js",
    "aiProvider": "gemini",
    "instructions": "Process user stories"
  }
}

Direct Execution

# Run JavaScript agent directly
dmtools run-js agents/js/myAgent.js --param ticketKey=PROJ-123

# With JSON parameters
echo '{"ticketKey":"PROJ-123"}' | dmtools run-js agents/js/myAgent.js --stdin

Common Issues

Issue: “Function not found”

// Ensure MCP tool name is correct
// ❌ Wrong
const ticket = get_jira_ticket("PROJ-123");

// ✅ Correct
const ticket = jira_get_ticket("PROJ-123");

Issue: “Cannot parse JSON”

// Always validate JSON before parsing
try {
    const data = JSON.parse(response);
} catch (e) {
    console.error("Invalid JSON:", response);
    return { error: "Invalid JSON response" };
}

Issue: “Rate limit exceeded”

// Add delays between API calls
function processWithDelay(items) {
    for (const item of items) {
        processItem(item);
        sleep(100);  // 100ms delay
    }
}

Runtime Property Switching with set_env_variable()

When a workflow needs to interact with multiple GitHub organisations (or other integrations requiring different credentials), you can switch the active credential at runtime using set_env_variable().

How it works

set_env_variable(propertyName, envVarName) tells dmtools to read a different environment variable for the given property. The actual secret value stays inside Java — the JS agent only provides the name of the env var, never the value itself.

Setup (environment / CI secrets)

Define your credentials as separate environment variables in your CI configuration:

FIRST_GITHUB_TOKEN=...    # token for first organisation
SECOND_GITHUB_TOKEN=...   # token for second organisation

Usage in JS agents

function action(params) {
    // Switch to first organisation's token
    set_env_variable("SOURCE_GITHUB_TOKEN", "FIRST_GITHUB_TOKEN");
    github_trigger_workflow({
        workspace: "first-org",
        repository: "some-repo",
        workflowId: "deploy.yml",
        inputs: "{}"
    });

    // Switch to second organisation's token
    set_env_variable("SOURCE_GITHUB_TOKEN", "SECOND_GITHUB_TOKEN");
    github_create_pull_request({
        workspace: "second-org",
        repository: "another-repo",
        sourceBranch: "feature/my-branch",
        targetBranch: "main",
        title: "My PR"
    });

    return { status: "done" };
}

Security guarantees

What JS seesWhat stays in Java
"FIRST_GITHUB_TOKEN" (string name)ghp_xxxx... (actual token value)
  • The env var name is validated: only uppercase letters, digits, and underscores ([A-Z0-9_]) are accepted.
  • If the env var is not set, dmtools throws an error — the message contains the variable name only, never the token value.
  • The token value is never logged or returned to JS context.

Supported properties

Currently set_env_variable() supports switching the GitHub token:

Property nameEffect
SOURCE_GITHUB_TOKENRecreates the GitHub client with the new token

Next: Teammate Configurations | MCP Tools Usage Examples

Edit this page on GitHub →

Rendered from dmtools-ai-docs/references/agents/javascript-agents.md, the same Markdown the DMTools agent skill reads. Last updated .