Automation Scripts
Automation Scripts
Section titled “Automation Scripts”atlcli script runs small TypeScript (or JavaScript) programs against Jira and Confluence. A script reads live data. Its writes are not executed while it runs: they become a plan that you review, and nothing changes until you apply it. Scripts run in a sandbox without Node access. External HTTP, local files and the script’s own state go through atlcli.* too, and their writes are planned the same way, so a script you did not write yourself can only do what its plan shows.
for await (const issue of atlcli.jira.search("project = ATLCLI AND updated < -180d")) { await atlcli.jira.comment(issue.key, "Closing as stale"); await atlcli.jira.transition(issue.key, "Won't Do");}That is a complete script: no imports, no exports, no setup. Larger scripts can split helpers into files they import.
For thousands of issues, let the script handle one issue at a time and atlcli run it as a resumable job: see Per-Issue Script Jobs.
On this page
Section titled “On this page”- Prerequisites
- Steps: your first script
- Writing scripts
- Plan and apply
- Other services, files and state
- Write reports and exports
- Splitting a script into files
- Restricting a script
- Testing a script
- Editor support and type checking
- Examples
- Limits
- Troubleshooting
- Related topics
Prerequisites
Section titled “Prerequisites”- An authenticated profile (
atlcli auth login). Scripts use the active profile, or the one given with--profile. - Jira permission: Browse Projects to read; Edit Issues, Transition Issues and Add Comments for the matching writes.
- Confluence permission: View to read; Add Pages and Add Labels for the matching writes.
- Optional, for type checking:
tscon yourPATH(npm install -g typescript).
A script never gets more rights than the profile it runs with.
Steps: your first script
Section titled “Steps: your first script”-
Create a folder with editor types, a
tsconfig.jsonand an example:Terminal window atlcli script init scripts--example <name>writes one of the bundled examples instead (atlcli script init --list-exampleslists them), and--each "<jql>"a per-issue skeleton. -
Run the example. It only reads, so the plan is empty:
Terminal window atlcli script run scripts/example.ts -
Write a script that changes something,
scripts/stale.ts:for await (const issue of atlcli.jira.search(`project = ${atlcli.params.project} AND updated < -180d`, { limit: 20 })) {await atlcli.jira.comment(issue.key, "Closing as stale");} -
Run it. You see the plan; nothing is written yet:
Terminal window atlcli script run scripts/stale.ts --param project=ATLCLIstale → https://company.atlassian.net (profile work)Plan: 2 operations1 jira comment ATLCLI-12 "Closing as stale"2 jira comment ATLCLI-31 "Closing as stale"Nothing was changed. Apply with --apply, or: atlcli script apply <plan.json> -
Apply it. atlcli asks before it writes:
Terminal window atlcli script run scripts/stale.ts --param project=ATLCLI --applyIn CI, cron or any other run without a person at the terminal, add
--yes: the plan is applied without asking. Without a terminal (or with--json), atlcli never waits for an answer: a run without--yesstops withApplying needs confirmationand writes nothing. To keep a review step, write the plan with--plan-out plan.jsonin one job and apply it withatlcli script apply plan.json --yesafter approval, see Scripts in CI.
Writing scripts
Section titled “Writing scripts”A script is an ES module, and its body is the program. These are always available:
| Name | What it is |
|---|---|
atlcli.jira.*, atlcli.confluence.* |
The verbs, see the Script API reference |
atlcli.params |
Values from --param name=value (strings, unless typed in meta) |
console.log/info/warn/error |
Output to the terminal (stderr); never part of the plan |
Top-level await |
Works anywhere in the file |
Searching. atlcli.jira.search and atlcli.confluence.search are async iterators that page through all results. limit stops early:
for await (const page of atlcli.confluence.search("space = DOCS AND label = draft", { limit: 10 })) { console.log(page.id, page.title);}Pages are Markdown. getPage returns the body as Markdown, and createPage, updatePage and upsertPage take Markdown. The conversion is the same one wiki docs uses. Jira descriptions and comments are Markdown too.
Returning a result. export default sets what the run returns. A value is used as it is; a function is called with the params. The result is printed after the plan, and with --json it is in plan.value:
let open = 0;for await (const _ of atlcli.jira.search("project = ATLCLI AND statusCategory != Done")) open++;export default { open };What no verb covers. atlcli.jira.rest(path) and atlcli.confluence.rest(path) read any REST resource of the site, for example an issue’s changelog. atlcli.jira.request(method, path, { body }) and atlcli.confluence.request(…) write one, as a planned operation like every other write:
const changelog = await atlcli.jira.rest("/rest/api/3/issue/ATLCLI-1/changelog", { query: { maxResults: 100 } });await atlcli.jira.request("PUT", "/rest/api/3/issue/ATLCLI-1/properties/review", { body: { state: "done" } });Prefer a verb where there is one: comments, worklogs, versions, components, ranking, clones, links, page properties, restrictions and blog posts have verbs, and most of them leave out writes that would change nothing. atlcli.jira.fieldId("Story Points") finds a custom field’s id by name.
JQL functions. Queries may use linkedIssuesOf("…"), subtasksOf("…"), hasAttachments("…") and the other JQL functions atlcli resolves.
One-liners. -e runs code without a file:
atlcli script run -e 'export default (await atlcli.jira.get("ATLCLI-1")).fields.summary'Plan and apply
Section titled “Plan and apply”Every write verb adds one operation to the plan instead of writing; reads go live. Writes cover issues (create, update, transition, comment, link, assign, labels, attachments), sprints (create, start, close, move issues), pages (create, update, upsert, move, delete to trash, labels, comments, attachments) and page exports to PDF or DOCX. The Script API reference lists them all.
Objects the plan creates have placeholder ids, plan:<n>, where n is the operation number. Use them like real ids: as a parent page, as the issue to comment on, or inside text. On apply each placeholder becomes the real key or id, inside Markdown as well:
const issue = await atlcli.jira.create({ project: "ATLCLI", type: "Task", summary: "Release 2026.10" });await atlcli.confluence.createPage({ space: "DOCS", title: "Release 2026.10", markdown: `Tracked in ${issue.key}` });The plan lists the page as (after #1). Once applied, the page says Tracked in ATLCLI-187.
Updates that change nothing are left out. When updatePage or upsertPage would write the body and title the page already has, the plan skips it and lists it under Unchanged, not planned. The same goes for assigning an issue to its current assignee, label changes that change nothing and moving a page to the parent it already has. Re-running a report script therefore plans nothing when the report did not change. This applies to the first write to an object in a run: a later write that restores an earlier state is kept.
Attachments and exports. Scripts cannot read local files, so attach takes content the script builds (text, or base64). confluence.export writes a PDF or DOCX file when the plan is applied, below the directory you apply in.
Three ways to apply:
| Command | When |
|---|---|
run <file> --apply |
Interactive: shows the plan, asks, applies |
run <file> --apply --yes |
Scheduled jobs; no question |
run <file> --plan-out plan.json, later apply plan.json |
Review first, for example in a pull request, then apply exactly that plan |
What apply checks. Before the first write, atlcli checks every target against the state the plan saw:
- Issues the plan changes must not have been updated since.
- Pages the plan updates must still be at the same version.
- Pages the plan creates must still not exist.
If anything changed, nothing is written and the command exits with code 2. Run the script again to get a fresh plan. A plan file is also bound to its site and protected by a digest: a changed file, or a file made for another site, is refused.
If a write fails (permissions, a workflow rule, a validator), atlcli stops there. The operations before it stay applied, and the report lists every operation as applied, failed or skipped. When the plan came from a file (--plan-out, or apply plan.json), atlcli records every applied operation in plan.json.journal.jsonl next to it. Fix the cause and continue with the rest:
atlcli script apply plan.json --resumeOperations already applied are skipped, and objects they created are used as before. Applying the same plan again without --resume is refused, because it would repeat its comments and creates. Without a plan file there is no journal: run the script again for a fresh plan.
Large plans. From 200 operations on, the output lists counts per verb and the first 50 operations; --plan-out or --json has all of them. --concurrency <n> applies up to n operations at once. Operations on the same issue, page or sprint, and operations that use an object the plan creates, still run in plan order.
Other services, files and state
Section titled “Other services, files and state”HTTP. atlcli.http.get reads from another service while the plan is made; atlcli.http.request is a planned write, sent when you apply. Every host needs a grant in meta.permissions, even when the script declares nothing else:
export const meta = { permissions: ["jira:read", "jira:write:ATLCLI", "http:status.example.com"] };
const health = await atlcli.http.get("https://status.example.com/api/v2/summary.json");const status = health.json as { status?: { indicator?: string } } | undefined;if (status?.status?.indicator !== "none") await atlcli.jira.comment("ATLCLI-1", "Status page reports an incident");Secrets. Tokens and webhook URLs stay out of scripts and plans. Declare the name in meta.secrets, set ATLCLI_SECRET_<NAME> in the environment, and use atlcli.secret("NAME") in a URL, header or body. The script only ever sees a reference; the host fills in the value when the request is sent:
export const meta = { permissions: ["http:hooks.slack.com"], secrets: ["SLACK_WEBHOOK"] };await atlcli.http.request("POST", `https://hooks.slack.com/services/${atlcli.secret("SLACK_WEBHOOK")}`, { json: { text: "Deployed" } });export ATLCLI_SECRET_SLACK_WEBHOOK=T000/B000/XXXXatlcli script run notify.ts --applyFiles. atlcli.files.read and files.list read below the working directory; files.write is planned, so the file appears only when you apply. For reports and exports that you want on every run, also without --apply, see Write reports and exports. Read a CSV, write Markdown:
const keys = (await atlcli.files.read("keys.csv")).trim().split("\n").slice(1);for (const key of keys) await atlcli.jira.addLabels(key, ["migrated"]);await atlcli.files.write("reports/migrated.md", `# Migrated\n\n${keys.map((k) => `- ${k}`).join("\n")}\n`);files.read reads up to 10 MB at once. files.lines reads a file of any size record by record: CSV rows as arrays of fields (quoted fields may span lines), JSON Lines as values, other files as text lines. The format follows the extension (.csv, .jsonl/.ndjson) unless you pass format:
for await (const record of atlcli.files.lines("exports/issues.jsonl", { limit: 1000 })) { const { key } = record as { key: string }; await atlcli.jira.addLabels(key, ["imported"]);}State. atlcli.state keeps values between runs, one store per script (~/.atlcli/state/<name>.json). state.set is planned like any write, so a run without --apply leaves the state as it was:
const since = (await atlcli.state.get("since")) ?? "-1d";for await (const issue of atlcli.jira.search(`project = ATLCLI AND created >= "${since}"`)) console.log(issue.key);await atlcli.state.set("since", new Date().toISOString().slice(0, 16).replace("T", " "));See Script API: HTTP and secrets, Files and State.
Write reports and exports
Section titled “Write reports and exports”atlcli.output(path) opens a file and returns a writer. Output files are results, like stdout: they are written while the script runs, also in a dry run without --apply, and they are not part of the plan. A file appears only once the script calls close(), so a failed run leaves no half-written file and keeps an existing one unchanged.
| Method | Writes |
|---|---|
out.write(text), out.line(text?) |
text, or text and a line break |
out.row(cells) |
one CSV row (RFC 4180 quoting, CRLF) |
out.json(value) |
one JSON Lines record |
out.render(template, vars?) |
a Jinja template, rendered straight into the file |
await out.close() |
keeps the file; returns { path, bytes, sha256 } |
Await the write methods in loops: text is handed over in 64 KB chunks, and more than 16 MB waiting to be written is an error.
Minimal: a CSV of open bugs.
const out = await atlcli.output("reports/open-bugs.csv");await out.row(["key", "summary", "status"]);for await (const issue of atlcli.jira.search("project = ATLCLI AND type = Bug AND statusCategory != Done", { fields: ["summary", "status"] })) { await out.row([issue.key, issue.fields.summary, (issue.fields.status as { name: string }).name]);}await out.close();atlcli script run open-bugs.ts# Wrote reports/open-bugs.csv (14 KB)Realistic: a Markdown report from a Jinja template. The template can live in its own file next to the script. render sees the --var values, today and now, and the vars you pass:
export const meta = { name: "sprint-report", permissions: ["jira:read", "file:read", "file:output"], params: { board: { type: "number" } },};
let sprint: JiraSprint | undefined;for await (const s of atlcli.jira.sprints(atlcli.params.board, { state: "active" })) sprint ??= s;if (!sprint) throw new Error("No active sprint");
const issues = [];for await (const issue of atlcli.jira.search(`sprint = ${sprint.id} ORDER BY status`, { fields: ["summary", "status", "assignee"] })) { issues.push({ key: issue.key, summary: issue.fields.summary, status: (issue.fields.status as { name: string }).name, assignee: (issue.fields.assignee as { displayName?: string } | null)?.displayName ?? "Unassigned", });}
const out = await atlcli.output(`reports/${sprint.name}.md`);await out.render(await atlcli.files.read("templates/sprint.md.j2"), { sprint, issues }, { templates: { "row.md.j2": await atlcli.files.read("templates/row.md.j2") },});const { path, bytes } = await out.close();export default { path, bytes };# {{ sprint.name }} ({{ today }})
{{ issues | length }} issues in the sprint.
| Key | Summary | Status | Assignee ||---|---|---|---|{% for issue in issues %}{% include "row.md.j2" %}{% endfor %}atlcli script run sprint-report.ts --param board=42 --out-dir build- Where files go. Paths are relative and stay below
--out-dir(default: the working directory; created if missing). - How much. One run writes at most 50 files and 256 MB in total; raise the size with
--max-output 1g. In a per-issue job the limits cover all issues together, and each path can be written once per run. - Close every file. An output left open fails the run with
was not closed (await out.close()); in a per-issue job it fails only that issue. - Permission. Scripts that declare
meta.permissionsneedfile:output. --jsonaddsoutputs: [{ path, bytes, sha256 }]to the result.
files.write is the other way to write a file: it is part of the plan, written only on apply and limited to 10 MB. Use it when the file is a change you want reviewed; use atlcli.output for reports. See Script API: output files.
Splitting a script into files
Section titled “Splitting a script into files”Import helpers with relative paths. Only files below the script’s directory load; packages and Node modules do not:
export const line = (key: string, summary: string) => `- ${key}: ${summary}`;import { line } from "./lib/format.ts";const lines: string[] = [];for await (const issue of atlcli.jira.search('fixVersion = "2026.11"')) lines.push(line(issue.key, String(issue.fields.summary)));await atlcli.files.write("release-notes.md", lines.join("\n") + "\n");The plan’s script digest covers every imported file, and per-issue jobs keep them for resume.
Restricting a script
Section titled “Restricting a script”Without meta, a script may use every verb. Writes still only reach Jira or Confluence through a plan you apply. For scripts you share, or ones generated by an AI assistant, declare what the script may do:
export const meta = { name: "close-stale", description: "Comment on and close stale issues", permissions: ["jira:read", "jira:write:ATLCLI"], limits: { maxWrites: 50 }, params: { days: { type: "number", default: 180 } },};
for await (const issue of atlcli.jira.search(`project = ATLCLI AND updated < -${atlcli.params.days}d`)) { await atlcli.jira.comment(issue.key, "Closing as stale");}permissions: once given, everything else is denied. Write grants name one project or space, or*for all (jira:write:*).file:readandfile:writecover local files,file:outputoutput files,http:<host>one host for HTTP. A denied verb ends the run, even if the script catches the error.secrets: the names of the secrets the script uses; a missing one stops the run before it starts.limits.maxWrites: the most operations a plan may contain (default 10,000).params: typed parameters with defaults. Unknown or mistyped--paramvalues are rejected.
atlcli reads meta before any of the script’s code runs. It must be written exactly as export const meta = { … }, a literal without variables or calls. See Script API: meta.
Testing a script
Section titled “Testing a script”atlcli script test runs a script against recorded data instead of the site, so you can change it with confidence and check it in CI without credentials:
# Once, against the site: nothing changes, the reads and the plan are storedatlcli script test triage.ts --record "two open bugs" --param project=ATLCLI
# Any time, offline: the plan must still be the sameatlcli script test triage.tsatlcli script test scripts/ # every script with a .test.json
# After an intended changeatlcli script test triage.ts --updateA failing case shows the operations that differ. For checks that a snapshot does not express, export a test function; it gets the plan of each case:
export function test(plan: { operations: Array<{ verb: string }> }) { if (plan.operations.some((op) => op.verb === "jira.deleteIssue")) throw new Error("triage must never delete");}Output files are kept in memory during tests and never written. A recorded case stores their content (up to 64 KB, else a SHA-256), and a replay reports Output <path> differs, was not written or was written but is not expected.
<script>.test.json holds what the script read from the site. Review it before you commit it; secret values are never recorded. See Script API: script tests.
Editor support and type checking
Section titled “Editor support and type checking”atlcli script init writes atlcli.d.ts next to your scripts. Editors pick it up through the generated tsconfig.json, so you get completion for every verb, its options and its return value. Annotate meta to have it checked as well:
export const meta: ScriptMeta = { permissions: ["jira:read"] };Check a script without running it:
atlcli script check scripts/stale.tscheck validates meta and the script’s imports and runs tsc --noEmit when tsc is on your PATH; otherwise it says it skipped the type check. Scripts may use TypeScript syntax that can simply be removed (types, interfaces, satisfies). They may not use enum or namespace.
Re-run atlcli script init after updating atlcli: it refreshes atlcli.d.ts and keeps your other files.
Examples
Section titled “Examples”Minimal: count open issues per assignee
Section titled “Minimal: count open issues per assignee”const counts: Record<string, number> = {};for await (const issue of atlcli.jira.search("project = ATLCLI AND statusCategory != Done", { fields: ["assignee"] })) { const who = (issue.fields.assignee as { displayName?: string } | null)?.displayName ?? "Unassigned"; counts[who] = (counts[who] ?? 0) + 1;}export default counts;Advanced: release notes page from a fix version
Section titled “Advanced: release notes page from a fix version”export const meta = { name: "release-notes", permissions: ["jira:read", "confluence:write:DOCS"], params: { version: { type: "string" } },};
const { version } = atlcli.params;const lines: string[] = [];for await (const issue of atlcli.jira.search(`project = ATLCLI AND fixVersion = "${version}" ORDER BY key`, { fields: ["summary", "issuetype"] })) { const type = (issue.fields.issuetype as { name: string }).name; lines.push(`- **${issue.key}** (${type}): ${issue.fields.summary}`);}
const page = await atlcli.confluence.upsertPage({ space: "DOCS", title: `Release notes ${version}`, markdown: `# Release ${version}\n\n${lines.join("\n") || "_No issues._"}`,});await atlcli.confluence.addLabels(page.id, ["release-notes"]);export default { issues: lines.length, created: page.created };atlcli script run release-notes.ts --param version=2026.10 --applyRun it twice: the second run updates the same page instead of creating a new one. If nothing changed since the last run, the update is left out of the plan, and the output lists the page under Unchanged, not planned. For running scripts from CI, see Scripts in CI.
Advanced: sprint rollover with a status report
Section titled “Advanced: sprint rollover with a status report”Moves every unfinished issue of the active sprint into the next one, attaches a CSV of what moved, and exports the report page as PDF:
export const meta = { name: "sprint-rollover", permissions: ["jira:read", "jira:write:ATLCLI", "confluence:read", "confluence:write:DOCS"], params: { board: { type: "number" }, report: { type: "string" } },};
const { board, report } = atlcli.params;const sprints: Record<string, JiraSprint | undefined> = {};for await (const s of atlcli.jira.sprints(board)) if (s.state !== "closed") sprints[s.state] ??= s;const { active, future: next } = sprints;if (!active || !next) throw new Error("Needs an active and a future sprint");
const rows = ["key,summary"];const keys: string[] = [];for await (const issue of atlcli.jira.search(`sprint = ${active.id} AND statusCategory != Done`, { fields: ["summary"] })) { keys.push(issue.key); rows.push(`${issue.key},"${String(issue.fields.summary).replaceAll('"', '""')}"`);}if (keys.length) await atlcli.jira.moveToSprint(next.id, keys);await atlcli.confluence.attach(report, { name: `rollover-${active.name}.csv`, content: rows.join("\n") });await atlcli.confluence.export(report, { format: "pdf", path: `reports/${active.name}.pdf` });export default { moved: keys.length, into: next.name };Closing the old sprint is a separate step on purpose: updateSprint(active.id, { state: "closed" }) cannot be undone.
Limits
Section titled “Limits”By default a script gets 64 MB of memory, 1 hour per run, 100,000 Jira or Confluence calls and 10,000 planned writes. A script that hits a limit ends with an error that names it. Raise or lower the limits per run; 0 removes a limit:
atlcli script run migrate.ts --timeout 0 --max-calls 0 --max-writes 50000 --memory 256--max-writes overrides meta.limits.maxWrites. For work on many issues, a per-issue job has no total limit at all: its limits apply to each issue. Each Jira, Confluence or HTTP call may take up to 60 seconds; HTTP responses and files are limited to 10 MB. See Script API: sandbox limits.
Troubleshooting
Section titled “Troubleshooting”Script failed: output reports/x.csv was not closed (await out.close()); nothing written
The script opened an output file and ended without closing it. Call await out.close() after the last write, also on early returns.
Output files exceed 256 MB (at export.jsonl); raise the limit with --max-output
The run’s output files together are larger than the limit. Pass --max-output 1g, or write less.
Script failed (guest): …
The script threw an error. The message is the script’s own error, or the error Jira or Confluence returned (for example Jira API error (404) for a missing issue). Catch it in the script if a missing object is expected.
Transition "Done" is not available for ATLCLI-12 (available: Zu erledigen, In Arbeit, Fertig)
Transition names come from the workflow and may be localized. Matching ignores case. For issues that already exist, the name is checked when the plan is made. For issues the plan creates, it can only be checked during apply. List the names with atlcli jira issue transitions ATLCLI-12.
Targets changed since the plan was made; nothing was written (exit 2)
Someone edited an issue or page between the plan and the apply. Run the script again to plan against the current state.
Script failed (write-limit) / (host-call-limit) / (interrupted)
The run hit a limit. If the size is expected (a migration, a large space), raise it with --max-writes, --max-calls or --timeout (0 for none), or turn the script into a per-issue job. Otherwise check the script for a loop that never ends.
3 of 10 operations of this plan were already applied (plan.json.journal.jsonl). Continue with --resume
An earlier apply of this plan stopped partway. apply plan.json --resume applies the rest. To start over, plan again instead.
Script failed (permission-denied): Script meta.permissions does not grant "jira:write:OPS"
The script’s meta.permissions does not include that project or space. Add the grant, or remove permissions to allow everything.
Starting sprint 1349 needs a startDate and an endDate
Jira starts a sprint only with both dates. Pass them with state: "active", or set them on the sprint first.
Unknown link type "duplicates" (available: blocks / is blocked by, …)
Link types are the site’s own and may be renamed or localized. Use one of the listed phrases, or the type’s name.
Export path must be relative and stay inside the output directory
confluence.export writes only below the directory apply runs in. Use a relative path such as reports/q3.pdf.
Applying needs confirmation: pass --yes
There is no terminal to ask in (CI, a pipe) or --json is set. Add --yes once you have reviewed the plan.
… is created by this plan and can only be read after it is applied
A read used a plan:<n> placeholder. Reads see Jira and Confluence as they are, without the plan’s writes. Keep the values you need in variables instead of reading them back.
meta must be a self-contained literal / Declare meta as export const meta = { … }
meta is read before the script runs, so it cannot use variables, calls or export { meta }. Write the values out.
Imports are not allowed in scripts except relative files / Import leaves the script's directory
Scripts import only their own files, below the script’s directory. Packages and node: modules are not available in the sandbox; copy what you need into a helper file.
HTTP to api.example.com needs "http:api.example.com" in meta.permissions
HTTP is never allowed implicitly. Add a grant for the host (http:*.example.com for its subdomains).
Secret SLACK_WEBHOOK is not set: export ATLCLI_SECRET_SLACK_WEBHOOK
Set the variable where the script runs and where the plan is applied. A secret used in atlcli.secret but missing from meta.secrets is refused as well.
The fixture has no answer for jiraGetIssue("ATLCLI-9")
script test replays only the reads it recorded. The script now reads something new: record the case again with --record.
Related topics
Section titled “Related topics”- Per-Issue Script Jobs: one script run per issue, for thousands of issues, resumable
- Script API reference: every verb,
metafield, limit and output shape; HTTP, secrets, files, state, imports and script tests - Scripts in CI: review plans in pull requests, apply after approval, scheduled housekeeping
- Jira bulk operations: single commands for common bulk changes
- Jira search: JQL for
atlcli.jira.search - Confluence search: CQL for
atlcli.confluence.search - Agent skill: how coding agents use
atlcli script