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
The Recommended Approach: JSRunner
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 invocationExposed MCP tool <name> to JavaScriptfor 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
| Situation | Recommended approach |
|---|---|
| Testing logic with real Jira/Confluence data | dmtools run agents/js/myScript.js '{"ticketKey":"PROJ-123"}' |
| Testing a post-action with ticket + AI response | Full JSRunner JSON config with ticket and response fields |
| Safe end-to-end test without side effects | Add dryRun: true param + guard in agent |
| Testing pure JS logic (no API calls) | node test_agent.js with stubbed MCP functions |
| Debugging unexpected behaviour | Add 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 sees | What 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 name | Effect |
|---|---|
SOURCE_GITHUB_TOKEN | Recreates the GitHub client with the new token |
Next: Teammate Configurations | MCP Tools Usage Examples