Docs

Using toil

What every toil command shares, in detail - the envelope and errors, the contract version, --select, big results (--summary-file and --output-format toon), toil wait, paging and progress, writes and their exceptions, global flags, files and the workspace, configuration, the raw API passthrough, dossiers, the audit log, using toil from a program (@imagile-dev/toil/api), sending feedback, certificates and proxies

Docs Using toil

SKILL.md has the rules every call needs. This page has the detail behind them, for when a call needs more than a command’s own flags.

The jira, jira-cloud, ado and ado-cloud commands are toil <area> <resource> <action> [ids...]: ids are positional, everything else is a flag, and toil <area> <resource> --help lists what a resource can do (toil jira issue --help).

The envelope and errors

Every command prints one JSON envelope on stdout: { ok, data, error, meta }. Check ok; on failure, error.code says what kind of failure it was and error.fix says what to do about it. New codes may appear in any release, so treat a code you don’t know as a generic failure. meta.warnings lists anything worth knowing that isn’t a failure, such as a truncated result. meta.context says which connection a call used and how it was chosen, and where any project, repo or environment it resolved came from. meta.lint, on the db commands that run SQL you wrote, names the engine whose dialect toil checked it against and the rules that ran (db.md, Pre-flight lint). --pretty formats the output for people.

meta.contractVersion (also in toil version) is the version of toil’s command contract: its commands, flags, data fields, envelope and error codes. It goes up only with a breaking change, so a script or program built against one version works with every toil that reports it.

Paging

Paged results put the next page’s cursor in meta.page.nextCursor (null on the last page) and the page size in meta.page.limit. Pass the cursor back with the command’s own paging flag, which its reference names.

Progress

While a command runs, toil says what it’s doing: each page a listing reads, and each rate-limited (429) request it waits to retry. On the command line these go to stderr, one plain [toil] … line each: a retry and how long toil waits always, piped or not, and pages only when stderr is a terminal. stdout still holds only the envelope. Over MCP they’re progress notifications, sent when the client’s call asks for progress. A program using toil’s API gets them, and every request too, through onProgress (Using toil from a program). They never change the envelope.

Selecting fields

To get back only the fields you need, pass --select <expression> (select over MCP), a JMESPath expression applied to data, for example --select "issues[].{key: key, status: fields.status.name}". meta, including meta.page, is left as it is. A bad expression is INVALID_INPUT before the command runs, with its position in error.fix; one that selects nothing gives data: null and a SELECT_EMPTY warning.

Big results

A search or listing can return far more than you need to read. Two flags on every command (fields over MCP) keep it out of your context.

--summary-file <file> (summaryFile) writes the whole of data, as JSON, to that file in the workspace (it must not exist yet; toil never overwrites), and puts a summary in data instead. Use it for any read that may return more than a page. Fixed rules pick the summary, the first that matches:

Resultdata holds
No list in it, 20 fields or fewer, and an estimated 1,500 tokens or fewerThe whole result
50 rows or fewer, and an estimated 1,500 tokens or fewerThe whole result
No list, or rows that don’t share 80% of their fieldskeys, lengths (of each list, object and long text) and one sample, its long text cut short (a bare text keeps its first 2,000 characters)
500 rows or fewerfields (what’s beside the list), the first 20 rows, stats and file
More than 500 rowsfields, the column schema, 10 rows spread from first to last, stats and file, with a SUMMARY_LARGE warning

The rows are the result itself when it’s a list, else the list in it with the most cells, its rows times their width (issues in { total, issues }, findings.results a level down, or rows beside a db query’s columns). Size doesn’t count, so one long text can’t make a one-row list the table, and a list of objects that names the columns of a sibling’s list rows, or is exactly { name, type } columns beside an empty list (a db query’s columns), is never the table. Nested rows are compared by their dot paths (fields.status.name). stats has, for each column (the first 25), nulls, distinct, and min and max for numbers and dates (null otherwise). Every summary is held to the 1,500-token budget: texts longer than 200 characters are cut short (ending in …), then rows are dropped from the end (a last row still too wide has its texts cut to 40 characters, and goes only if that doesn’t fit), fields beside the rows are dropped from the end, and an outline’s sample, lengths and keys are cut in that order. meta.summary says which rule applied (rule), how many rows the full result has (rows), where they are (table), the file, the token estimates (tokens, JSON characters / 3.5: an estimate, never a count), the column count, and anything the budget trimmed. After a SUMMARY_LARGE warning, work over the file rather than reading it in: a narrower query, --select, or a tool that reads JSON. The file is written even when the whole result fits, so it’s always there; a read-only API call refuses summaryFile, since it writes a file.

--output-format toon (outputFormat: "toon") writes data as TOON text: a list of uniform rows becomes one header (rows[20]{key,status}:) and one comma-separated line per row, so field names are paid for once rather than on every row. Only data changes: ok, error and meta stay JSON, and meta.format is "toon". It pays off for tables; deeply nested data with few repeated rows can come out longer than JSON. --output-format json is the default, and output without either flag is exactly what it always was. Both flags go together, the summary first:

toil util deps scan --include-transitive --summary-file deps.json --output-format toon

Waiting for something to finish

To wait for a build, a scan or a deployment to finish, don’t call a read in a loop: toil wait --until "<expression>" -- <read command> runs it again every --interval (default 15s, at least 5s) until the JMESPath expression over its data is true (not null, false or empty), for example toil wait --until "result != null" -- jenkins run get my-job 42 or toil wait --until "projectStatus.status != 'NONE'" --timeout 10m -- sonar quality-gate get --project my-app. Over MCP it’s the wait tool, with tool (the read’s tool name), input (that tool’s input) and the same until, interval and timeout. It returns the read’s last envelope plus meta.wait (until, attempts, elapsedMs). NETWORK errors and HTTP 5xx or 429 answers are retried with backoff; any other error ends the wait with that error. Past --timeout (at least 5s; default 30m, or TOIL_TIMEOUT_MS, cut to the maximum with a WAIT_TIMEOUT_CAPPED warning when it is longer; at most the "wait": { "maxTimeout" } in config, default 1h) it’s TIMEOUT, with the last data in error.details.data. Only reads can be waited on: a write is INVALID_INPUT. --select before wait applies to the final data; a --select after -- belongs to the read, so --until sees what it selected. Other global flags go before wait, --summary-file and --output-format included, and shape the final data.

Writes

Reads are always allowed. A command that changes something, in a service, a database or a file on this machine outside toil’s own config, needs both writes: true on the connection it uses, set by a person in their config or with a TOIL_CONN_<NAME>_WRITES / TOIL_<AREA>_WRITES env var, and --allow-write on the call (allowWrite: true over MCP). Without either it fails with WRITE_DISABLED, naming what’s missing. Over MCP a write tool isn’t listed until writes are on, and deletes are never tools. A command that is CLI only and has no connection yet (toil skills marketplace add) needs --allow-write alone.

Exceptions

Exactly four writes don’t follow that rule. Each is the maintainer’s choice, made deliberately, and is listed here with how to avoid it.

WriteWhat it writes, and whenWhyTo avoid it
toil skill installWith --allow-write alone (CLI only, no connection): the toil skill, into one agent’s skills folder as toil/, recorded as bundled.Its contents are fixed: the files embedded in toil, no third-party code. It replaces only a toil/ folder it recorded as bundled and refuses any other.Don’t run it.
The toil skill keeping itself currentWith no --allow-write: a toil/ skill folder recorded as bundled, and nothing else in a skills folder (never a marketplace or hand-made skill). Rewritten by any command that loads config, on the CLI or over MCP, when that command changed the config file (a connection added, changed or removed) or when the copy was written by another toil version. Said in meta.warnings as SKILL_REWRITTEN.So an installed skill never describes connections you no longer have, or a toil you no longer run, with no update command to forget. The contents are fixed; the description holds only area and engine names checked against toil’s own list, never a host or a credential; and a rewrite that fails is a SKILL_REWRITE_FAILED warning, never a failed command.Don’t install the skill with toil skill install (copy skills/toil yourself, or use the Claude Code plugin marketplace), or delete the installed toil/ folder. A call with --config <file> never rewrites it.
toil feedbackWith no connection and no --allow-write: one bug report or feature request, sent to Imagile LLC, which files it as a public GitHub issue. Only with --consent on the call, or yes typed at a terminal after it shows the report. CLI only.Feedback has no connection to hold a standing opt-in, so the approval of each report is the opt-in. It sends only what you typed, plus toil’s version and commit, and is the one request toil makes to a host you didn’t configure (Feedback, below).Don’t run it, or run it without --consent: it then sends nothing and shows what it would send.
The welcome menu’s game saveWith no --allow-write: the game’s progress, under the "game" key of ~/.toil/config.json, every other key kept as it was. Only while you play Two Weeks’ Notice in the menu bare toil opens.It’s a game a person plays at a terminal, and its save touches nothing but its own key. The save is sealed against tampering, not kept secret.Don’t play it. There is no setting to turn the save off.

The raw API passthrough

For an endpoint no command covers, an HTTP area has toil <area> api <METHOD> <path> (<area>_api over MCP), which sends the connection’s own credential to a path under its base URL. GET and HEAD are reads; other methods are writes, and DELETE is CLI only. The area’s reference describes it where the area has one (jira.md).

Files and the workspace

Files a command reads or writes (an --input-file, an upload, a download, a CSV report) must be inside the workspace: --workspace <dir>, else TOIL_WORKSPACE, else the working directory; under toil mcp, the client’s root when it shares exactly one. Relative paths resolve against it, a path outside is refused with INVALID_INPUT, and nothing is ever overwritten. Under toil mcp with no single root, a command that touches a file returns NOT_CONFIGURED: add --workspace <dir> to the server’s args. A person can let commands reach any path with "bounds": { "files": "unbounded" } in their config, and every envelope then carries a BOUND_RELAXED warning. Saved paths come back absolute, with forward slashes. Commands that run git need git on PATH and say GIT_NOT_FOUND without it; nothing else does.

Configuration

Configuration comes from environment variables, which always win, or from ~/.toil/config.json (or the file passed with --config). A connection is one instance of an area; --connection <name> picks one when there are several. toil connections list names them (never a secret), with definedIn: file for one in your config file, env for one made by env vars alone, which the connections commands can’t change. toil connections test <name> checks one with a single authenticated read, failing with the AUTH, NETWORK or config error a real command would. Setting up a connection, signing in, turning writes on and choosing a default are for a person: a bare toil in a terminal opens a menu for them.

Global flags

Global flags go before or after the command: --pretty, --verbose (adds every request to meta.requests, credentials redacted), --debug (traces on stderr), --config <file>, --output-file <file> (the whole envelope to a file, CLI only), --workspace <dir>, --select <expression>, --summary-file <file> and --output-format json|toon (Big results). --help on any command lists its own flags.

Dossiers

dossiers.md explains what a dossier is, with an example file field by field; this section is the short form.

A dossier holds what toil knows about one body of work: its repositories, and per area the keys a call needs (project, owner, a namespace per environment), which connection to use (by name, or by host), and reference data such as board lists. It holds no secret and can’t add a host or turn on writes. toil reads dossiers on every area command from ~/.toil/dossiers/, the folders in dossierPaths (or TOIL_DOSSIER_PATHS), and .toil/dossiers/ in the repository you’re in, and uses the one that applies: --dossier <name|file> (dossier over MCP, or TOIL_DOSSIER), else the one whose repo matches the folder by path or origin remote. --cwd <dir> (cwd over MCP) looks from another folder. A value you pass always beats the dossier’s, and meta.context.dossier says which dossier applied and why. When two different dossiers match, the call fails with INVALID_INPUT until you pick one. A write on a connection a dossier chose is WRITE_DISABLED unless the dossier is in your own folder; pass --connection to choose it yourself. To see what a call will use before making it: toil dossiers context (which dossier a folder resolves to and why), toil dossiers show [name] (per area: the connection and why, whether writes are on, the environment and the inputs), toil dossiers list, and toil dossiers check [name], which checks one locally and contacts no service.

To build one for a product: toil repos scan lists the git repositories under the workspace. Then:

  • toil dossiers init <name> creates the dossier.
  • toil dossiers add-repo <name> <folder|remote> adds each repository.
  • toil dossiers link <name> <area> --host <host> --set project=PAY [--for-repo <repo>] says which connection an area uses and the keys it supplies.
  • toil dossiers env <name> <env> <area> --set namespace=... adds an environment.
  • toil dossiers field <name> jira storyPoints customfield_10002 names a field, so --field storyPoints=5 works.
  • toil <area> check-dossier checks a section against the service, for the call’s environment (jira, jira-cloud, confluence, sonar, ado, bitbucket, github, openshift, dynatrace).
  • toil dossiers export <name> --repo <root> writes a shareable copy into a repository’s .toil/dossiers/. It uses host selectors and no local paths, and toil never commits it.
  • toil dossiers unlink <name> --area <area> [--env <env>] removes a section (CLI only).
  • toil dossiers import <file> copies a teammate’s into your own folder. A connection or host you lack is listed under toSetUp; toil never creates one.

These are local writes and need no --allow-write. Each refuses a value that’s already set unless --replace. Over MCP they refuse to make a dossier select a connection with writes on; the user does that on the command line, or under Dossiers in the menu bare toil opens, which runs these same commands and asks them to type the connection’s name first.

The audit log

toil keeps a local audit log of every command it runs, through the CLI, MCP and the API: one JSON line per call in ~/.toil/audit/<yyyy-mm>.jsonl, with the command, front door (via: cli, mcp or api, plus caller when a program named itself), connection, whether it wrote, the outcome, the redacted input and each request’s method, URL and status. It never holds a secret, a header, a body or a row, and db SQL is kept only as a SHA-256 hash. toil audit list reads it back, newest first, with --since <date>, --command <name|area>, --connection <name> and --outcome <ok|failed|code> and --writes-only; toil audit path says where it is and whether it’s on. Six months are kept by default ("audit": { "keepMonths": 12 } changes that), and only a person can turn it off, with "audit": { "enabled": false } in ~/.toil/config.json. A call whose line couldn’t be written still succeeds, with an AUDIT_WRITE_FAILED warning.

toil audit stats summarizes the same log, and reads nothing else. Over a window (--since, --until; by default from midnight UTC 30 days ago to now) it returns totals and one entry in groups per value of --by command|area|connection|caller|via|outcome|day (repeat --by for a cross-tab; the default is command; day is the UTC date): calls, ok, failed, the top three errors with each code’s general fix (the log keeps the code, never the call’s own message), the nearest-rank durationMs.median and p95, writes and writeShare, and firstSeen / lastSeen. Groups come most calls first, or in date order when day comes first. --area, --command, --caller and --outcome filter it, and --unused adds unused: every registered command, within the area and command filters, with no matching call in the window, and whether MCP serves it. It never groups by an input’s value, so a hashed statement stays a hash.

toil audit stats --by command --unused --area jira
toil audit stats --by day --by via --since 2026-10-01 --until 2026-10-18
toil audit list --command "jira issue get" --outcome failed --limit 20

Using toil from a program

A Node program can call toil in-process instead of running toil and parsing stdout. @imagile-dev/toil/api is a supported entry point of the same package, with its own types, and it adds no dependency: it is the same bundle as the CLI. Loading it runs nothing, even when your own build bundles it in: only toil’s bin starts the CLI.

import { contractVersion, execute, registry, version } from '@imagile-dev/toil/api';

if (contractVersion !== 1) throw new Error(`Needs toil's contract 1; toil ${version} has ${contractVersion}.`);
const envelope = await execute('jira issue search', { jql: 'project = PAY', limit: 50 }, {
  readOnly: true,
  caller: 'nightly-sync',
  onProgress: (event) => console.error(event.message),
});
  • execute(command, input, options) runs one command and returns its envelope, exactly as the CLI prints it. command is the name meta.command reports (jira issue search); input is the command’s MCP fields, checked strictly. It never throws: an unknown command or a bad option is an INVALID_INPUT envelope. Connections, env vars, the credential store, dossiers, the workspace (the program’s working directory, else workspace), the write gate and the audit log all apply as they do on the CLI. It serves what MCP serves: commands that prompt or change connections, and deletes, stay on the CLI, except as person below allows.
  • options: readOnly refuses, with WRITE_DISABLED and before anything is sent, any call that would change anything, whatever the config says: a write to a service, and a local one too (toil’s config or dossiers, a download, a file named with out). A read-only call writes nothing but its audit line. caller names the program in the audit log (lowercase letters, digits, ., _ and -, up to 64). signal cancels the call (CANCELLED). onProgress hears every request (redacted URL, status, time), page and retry. config, workspace and verbose match --config, --workspace and --verbose.
  • person is for a program with its own interface for a person, such as Toil UI’s settings pages. It is { ask(question) }, and with it execute() also runs the connections commands a person runs on a terminal: connections add, login, remove, writes and default, and feedback (registry.personCommands). Each question arrives as { id, text, secret, fallback }, and ask returns what the person typed. The id is one of area, setting:<key>, secret:<key>, confirm (y saves what they reviewed), confirm-name (the connection’s name, typed to turn writes on) or consent (yes sends the feedback report and disclaimer the question shows, Feedback, above). With readOnly, nothing is asked: feedback only previews. Return undefined for a question they weren’t asked, and the command saves or sends nothing (CANCELLED). A secret goes from the answer to the OS credential store: it is never in the input, the envelope or the audit log. The program must ask a person and pass on their answers. Nothing about this lets an agent run these commands.
  • registry lists every command execute() serves: its name, MCP tool name, whether it writes to a service (writes) or on this machine (writesLocal), and its input’s JSON Schema. It also lists the areas, each with connection: the fields connections add asks for, in order, each with secret, password, default and required.
  • version and contractVersion are what toil version reports. Check contractVersion before relying on anything else. toil version also reports commit, the 40-character hash of the commit the build came from (null when the build didn’t record one), which is what a bug report from a terminal names.
  • mapOwi(input, options) maps envelopes you already hold (one, an array, or JSON Lines text) to Open Work Items, the way toil util owi map does (util.md), synchronously and without the schema check: { items, histories, skipped, warnings }. options.fields is a dossier section’s fields object ({ estimate: 'customfield_10234', estimateUnit: 'points' }), command names the command for bare data, and key the item a keyless history belongs to. A refused input throws, with a fix. owiVersion is the OWI version items carry, and owiSchema their JSON Schema; execute('util owi map', { text }) maps and validates in one call.
  • findSimilar(records, request, options) finds likely duplicates among OWI records you already hold (mapOwi’s items, or the text util owi map printed), scored the way toil util owi similar scores them (util.md), in-process: { query } ranks them against some text, { item } against one of them by OWI id, and { pairs: true } lists likely duplicate pairs. options takes fields, limit and threshold. It returns { mode, method, count, matches, pairs, search }, each match and pair with its score and why, the same for the same records every time. For a very large set, use execute('util owi similar', …), which runs in a worker with a deadline.

What @imagile-dev/toil/api exports, and the types it names, follow the command contract: they change only additively unless contractVersion goes up. Anything else in the package is internal.

Feedback

toil feedback bug|feature sends a bug report or a feature request to Imagile LLC, where it becomes a public GitHub issue in imagile-dev/toil, and the reporter is emailed when it’s filed and when it closes. It is CLI only, never an MCP tool, and the one request toil makes to a host you didn’t configure. Every report carries this disclaimer, version 1, and nothing is sent until the person sending it agrees to it:

Your report is sent to Imagile LLC to improve toil, and it becomes a public GitHub issue in imagile-dev/toil. Your email address stays private: it is used only to tell you about your issue. Before you send, remove company data: internal URLs and hostnames, ticket keys, repository and project names, people’s names, other identifiers and credentials. You are responsible for what you send.

toil feedback bug --title "jira issue search ignores --limit" --body "toil jira issue search --limit 5 returns 50 issues." --email you@example.com
FlagMeaning
--title <text>One line, up to 120 characters
--body <text>The details, up to 4000 characters
--email <address>The reporter’s own address, kept private
--input-file <file>JSON with any of kind, title, body and email, for long text; a flag beats the file, and - reads stdin
--consentSend it. Without it nothing is sent

Without --consent, the command sends nothing. On a terminal it shows the report and the disclaimer and asks; anywhere else it returns sent: false, the exact report it would send, the disclaimer, consentVersion and how to approve, with a FEEDBACK_NOT_SENT warning. With --consent it sends that same report and returns sent: true and the endpoint’s message. The report is the kind, title, body and email given, plus version, commit and consent (the disclaimer’s version). toil never adds config, connection names, hosts or tokens. A build that doesn’t know its commit previews but can’t send (UNSUPPORTED); the form at https://imagile-dev.github.io/toil/feedback/ works from any browser.

An agent sending feedback for a user follows every step, in order, every time:

  1. Only when the user asks. Never offer to send a report on your own initiative, and never send one because a command failed.
  2. Scrub it. Replace company data with neutral placeholders before the user sees the draft: internal URLs and hostnames (jira.imagile.dev), ticket keys (PROJ-123), repository, project and team names (my-repo), people’s names (a colleague), email addresses other than the reporter’s own, IDs, tokens, passwords and anything else credential-shaped. Keep toil’s own command names, flags and error codes; they’re what makes a report useful.
  3. Show it. Run the command without --consent and show the user the full data.report it returned, and the data.disclaimer, word for word.
  4. Ask. Get the user’s explicit yes in chat to sending that exact report. A yes to an earlier draft, or to feedback in general, isn’t one.
  5. Send it. Only then run the same command again with --consent, unchanged. If the user asks for any change, make it, and go back to step 3.

Through @imagile-dev/toil/api, feedback runs only for a person (below), whose yes to the consent question sends it; the consent field is refused there, and a readOnly call only previews. TOIL_FEEDBACK_URL points the command at a local copy of the feedback function for testing, and takes only a loopback address (http://127.0.0.1:7071).

Certificates and proxies

toil trusts the OS certificate store and Node’s bundled CAs, and uses HTTPS_PROXY / HTTP_PROXY / NO_PROXY when they’re set. A NETWORK error about an untrusted certificate means the CA is in neither; NODE_EXTRA_CA_CERTS adds it.

skills/toil/core.md