Questions and troubleshooting

Frequently asked questions (FAQ), and what to do when something does not work, grouped by the part of workflow they concern. When something fails, start with Something does not work. Where do I start?. Each answer links the page that holds the detail.

When something fails · Installing · First run and the configuration file · When the configuration file is refused · Credentials and the keychain · Jira · Branches, commits and hooks · Git forge (GitHub and GitLab) · Slack and other chat services · Announcements · The terminal interface · The web interface · Taskwarrior · Scripting and automation · Local data and privacy

When something fails

Something does not work. Where do I start?

Run workflow doctor. It makes no network calls. It reports the version, the repository and its forge, the tools it finds (git, lefthook, gh, glab, Taskwarrior), where the store keeps its files, the configuration files in effect, and every required field that is empty or invalid. It ends with Credentials were not checked. Add --online to ask Jira, your forge and Slack; a webhook is left unchecked. Then run workflow doctor --online to ask each service whether its credential works and where each one came from. Its output masks every credential and never prints a webhook URL, so it is safe to paste into a bug report; What should I attach to a bug report says what else to add. See workflow doctor.

What should I attach to a bug report, and is --request-log safe to share?

Run workflow --request-log requests.log doctor --online (or whatever command failed) and attach the file. Each line is an outline of one request: the time, the service, the method, the path, the status and how long it took, as in 2026-09-22T18:04:11Z jira GET /rest/api/2/myself 200 184ms. It never records a header, a body, a query string or a host. A messaging webhook’s path is itself the credential, so it is redacted. The file is created readable only by you (0600), and it is appended to, never truncated. The old name --log is refused with exit 2 and the message the flag was renamed; it is --request-log now. See Scripting: A log for a bug report.

A request fails with “the server took too long to answer”. How do I give it longer?

Each request to Jira, the forge or the messaging service may take ten seconds. On the command line, one that takes longer fails with the server took too long to answer (waited 10s) and exit status 5; the terminal interface says the service was too slow. For a host that is slow to answer, raise timing.request_timeout, as in "timing": { "request_timeout": "30s" }. It also bounds the calls forge.cli hands to gh or glab, and how long quitting the terminal waits for writes still in flight. A service that is limiting requests says rate limited; wait and try again instead, also with status 5. See Configuration: Timing.

A request fails with “could not reach”, or names a certificate. What do I check?

When workflow cannot connect, the command line says could not reach the server at … for Jira, and could not reach the forge, could not reach the messaging service or could not reach Slack to refresh the user token for the others; the cause follows, and the command exits 5. The terminal interface says Jira did not answer, could not reach the forge or could not reach messaging, and the web says the service could not be reached. Check the network, a VPN, or HTTPS_PROXY, which workflow’s requests go through when it is set, first. A cause ending x509: certificate signed by unknown authority means your company signs its own certificates.

workflow has no setting that skips certificate checks. The requests it sends itself go through Go’s standard HTTPS transport, which trusts what your operating system trusts, so add your company’s root certificate to the system’s trust store. On Linux, Go also reads SSL_CERT_FILE or SSL_CERT_DIR if you would rather name a bundle for workflow alone. git’s pushes, and forge calls routed through gh or glab with forge.cli, use those tools’ own trust settings. jira.base_url must be https in any case; plain http is accepted only for localhost or a loopback address. See Configuration: Fields.

Installing

Which platforms is there a release binary for, and does workflow run on Windows?

Each release lists a binary for every platform it is built for, named workflow_<os>_<arch> (with .exe on Windows), and brew install jacob-delgado/tap/workflow says so on a platform no release is built for; there, go install github.com/jacob-delgado/workflow/cmd/workflow@latest builds it with Go alone, no C toolchain or Node needed. Windows is a secondary target: download its binary from the release (Homebrew is for macOS and Linux) and rename it workflow.exe in a directory on your PATH. CI compiles and vets the Windows build on every change but never runs it, so a Windows-only bug is worth reporting. A few things differ there: as on Linux, workflow drives no keychain (Does workflow use the keychain on Linux or Windows?), so the Slack user token’s credentials stay in the configuration file, and the file-ownership and permission checks refuse nothing, since Windows keeps no Unix owner or mode bits. See Install: From a release and Install: With Go.

How do I upgrade, and what might break when I do?

Upgrade the way you installed: brew upgrade workflow, go install github.com/jacob-delgado/workflow/cmd/workflow@latest, or download the new release binary. workflow --version prints the version, and so does the first line of workflow doctor. Before 1.0, a change in the minor version (0.12 to 0.13, say) means something you depend on changed: a configuration key, a command, a flag, an output or an exit status. New features and fixes only bump the patch. workflow keeps no compatibility shims for old formats, so read the release’s BREAKING CHANGES section in the changelog before you upgrade across a minor version. See Install: With Homebrew.

First run and the configuration file

What is the quickest way to get set up?

Run workflow config init. It asks for your Jira address and personal access token and checks them with Jira (on macOS it then offers to keep the token in the keychain). Then it asks for a Slack incoming webhook, which it saves unchecked; leave that blank to post with your user token after workflow slack login (Should I use a Slack user token or an incoming webhook?). It writes .workflow.json with mode 0600, at the repository root, or in the current directory outside one (--global writes ~/.workflow.json). Then run workflow doctor to see what is still missing (Something does not work. Where do I start?). You can also just run workflow or workflow --web with no file, and each offers to set one up with the same questions. With no terminal to answer on, workflow config init stops with no terminal to answer on; pass --template to write a file to edit by hand and exits 2, and workflow config init --template writes a blank file to fill in yourself. See Install: First run and Using workflow: Setting up.

Where does workflow look for .workflow.json, and which file wins?

workflow looks in the current directory first, then in each directory above it up to the repository root (the directory holding .git), and then in your home directory. Outside a repository, it reads only the current directory’s file and your home file. When both a nearer file and your home file exist, the nearer one is layered over the home one setting by setting: objects merge key by key, and anything else it sets, such as a list, a string or an explicit false, replaces the home value. So keep your Jira address, token and messaging in ~/.workflow.json, and keep only what a repository changes in its own file, such as { "jira": { "project": "OSS" } }. workflow doctor names the files in effect on its Configuration: line (for example /repo/.workflow.json over /home/you/.workflow.json), and workflow config show names them on stderr. See Configuration: Where it looks, and what wins.

config init says “configuration file already exists”. How do I start over?

workflow config init never writes over an existing file, because that file holds credentials you cannot get back. It stops with configuration file already exists: <path>; pass --force to overwrite and exits 4. Pass --force to replace it. A repository’s file that is a link is refused even with --force, because the write would follow the link. By default the file goes at the repository root, or in the current directory outside a repository; use --global to write ~/.workflow.json instead. Run in a repository when you already have a home file, it starts from the home file’s settings and writes only what you change, and --template writes an empty layer. See workflow config init.

Can I try setup, or workflow itself, without changing anything?

Yes, with --dry-run (-n). workflow config init --dry-run asks the same questions and runs the same Jira check, but writes nothing and stores nothing in the keychain. It prints the file it would write, credentials masked, on stdout, and dry run: would write <path> (mode 0600) on stderr; like a real run, it refuses a file that already exists unless you pass --force. Bare workflow --dry-run opens the interface with every write held back, and each action says what it would have done. workflow doctor --dry-run --online holds back the refresh of a Slack user token that is due one, since the refresh writes. See Using workflow: Dry run.

When the configuration file is refused

workflow refuses my configuration with “invalid .workflow.json”. What is wrong?

The text after invalid .workflow.json: names what is wrong. workflow refuses unknown keys instead of ignoring them, because a misspelled key that loaded silently would look just like a credential you never set. The error names the file and the key, as in /home/you/.workflow.json: invalid .workflow.json: json: unknown field "tokenn". Every command that reads the configuration stops, with exit status 3. Before 1.0, a breaking release can rename or remove a configuration key, and the old spelling is not kept. Look the key up in that release’s BREAKING CHANGES section of the changelog and fix the file by hand; workflow config init --force also works, but it replaces the whole file, credentials and all. The version field must be empty or "1"; anything else is refused with unknown configuration version. A value a setting cannot take is refused the same way, and its answer sits with that setting: the branch template, a user token and a webhook both set, jira.views, and ui.color and ui.motion; a setting only the home file may set has its own entry in this section. See Configuration: Fields.

Why is my configuration file refused, or flagged by doctor, for its permissions?

These are two separate checks. First, on Linux and macOS, every command refuses to read a configuration file someone else could have written, with someone other than you could have written this configuration file. That is either one another user owns (another user owns it), or one that others, or a group other than your own, may write (its mode, 0664, lets others write it; run chmod go-w on it). Fix it with chmod go-w <file>; this refusal exits 1. Second, workflow doctor fails while anyone but you can even read the file. It prints Permissions: with the mode, then It holds credentials. Make it yours alone with `chmod 600 <file>`., and exits 3. workflow config init writes the file with mode 0600, so this usually means the file was made or copied by hand. See Configuration: Keeping the tokens safe.

My repository’s .workflow.json is refused with “only the home directory’s file may set it”. Why?

Three settings run a program or read your environment: jira.token_command, jira.token_env and taskwarrior.program. A repository’s file is part of a working tree you may have cloned from anyone, so only your home file may set them. Any other file that sets one is refused when it loads, and so is the current directory’s file outside a repository. The error names the file and the setting, for example /repo/.workflow.json: invalid .workflow.json: jira.token_command: only the home directory's file may set it, and the exit status is 3. Move the setting to ~/.workflow.json; the repository’s file still layers over it. See Configuration: Where it looks, and what wins.

Credentials and the keychain

Where can each token come from, and which one wins?

For Jira, workflow takes the first of four sources: jira.token in the file, then the OS keychain ("keychain": true, macOS only), then the environment variable jira.token_env names, then the output of jira.token_command, which runs directly with no shell. For the forge, the order is the environment ($GITHUB_TOKEN or $GH_TOKEN for github.com, $GITLAB_TOKEN or $GLAB_TOKEN for gitlab.com or the host $GITLAB_HOST (or $GL_HOST) names), then gh auth token for that host (GitHub only; there is no glab step), then forge.token. So if you already use gh auth login, there is nothing to set; if workflow says no forge token found, see Where does workflow get my GitHub or GitLab token. A Slack user token is set up by workflow slack login (Where is my Slack token kept?); a webhook URL lives in the file. workflow doctor --online reports which source each credential came from, without printing it. If Jira refuses the token, see What do “the credential was not accepted” and “the credential was refused” mean?. See Configuration: Keeping tokens out of the file and Configuration: The forge token you probably do not need.

A repository’s file changes the Jira, forge or messaging address, and now there is no token. Why?

A credential is inherited only by the address it was written for. A repository’s file that changes jira.base_url does not inherit the home file’s jira.token, token_command, token_env or headers. One that changes forge.host does not inherit forge.token, and one that changes messaging.kind does not inherit the webhook or the Slack user token. This keeps a cloned repository from sending your token to an address of its choosing; with no token, a Jira call fails with no Jira token to send. Keep a token for the new address yourself. On macOS, "keychain": true reads the keychain item kept for the address in effect, which Settings or config init can fill. Settings refuses to write any other new credential into a repository’s file, and a token in your home file is not inherited at the new address either, so write the token into the repository’s file by hand and keep that file out of git. See Configuration: Where it looks, and what wins.

Does workflow use the keychain on Linux or Windows?

No. workflow drives only the macOS keychain, through the built-in security tool. It keeps the Jira token there in a generic password item named workflow-jira <jira.base_url>, and config init offers to store it there. On Linux and Windows, config init makes no keychain offer, and "keychain": true gives no token: Jira calls fail with workflow drives no keychain on this system: jira.keychain reads none here. On Linux, store the token with your own tool and set jira.token_command in ~/.workflow.json, for example secret-tool lookup service workflow-jira. On macOS, if the keychain is locked, answer the unlock prompt within 30 seconds, or the read or save fails. The Slack user token is kept there too, as workflow-slack (Where is my Slack token kept?). See Configuration: The operating system’s keychain.

Jira

Does workflow work with Jira Cloud?

No. workflow targets Jira Data Center and Server (on-premises), through REST API v2. It searches with /rest/api/2/search, which Cloud replaced with /search/jql in 2025, and it names users by username, while Cloud names them by account id. Point jira.base_url at your on-premises instance. If your team has no Jira, leave jira.base_url empty and use your forge’s issues instead (Can I use workflow without Jira?). See Configuration: Jira token (on-premises / Data Center).

Can I use workflow without Jira?

Yes. Leave jira.base_url empty, and your forge’s issues become the tracker: the Issues pane lists the open GitHub or GitLab issues assigned to you on the repository’s project. A branch for one is named by its number, as in feat/42-fix-typo. Change status offers only Close, and logging work, linking the pull request and jira.views don’t apply. workflow doctor --online then says not configured — the forge's issues are the tracker for Jira. To list forge issues alongside Jira’s, set "issues": {"forge": true} in the repository’s .workflow.json. See Using workflow: Pick up an issue.

Which Jira token do I need?

You need a personal access token (in Jira, avatar → Profile → Personal Access Tokens), set in jira.token or supplied by jira.keychain, jira.token_command or jira.token_env. Leave jira.user empty and the token is sent as a bearer token, which is what Data Center expects; set jira.user only if your instance wants HTTP Basic, and the token is then the password. workflow doctor names the mode on its Jira line (bearer token or basic auth), and workflow doctor --online asks Jira who the token is. See Configuration: Jira token (on-premises / Data Center).

What do “the credential was not accepted” and “the credential was refused” mean?

the credential was not accepted means Jira did not take the token (a 401, or an answer as the anonymous user): it is wrong, expired or revoked, so make a new one. the credential was refused is a 403: Jira knows the token but will not let its account do this, so check the account’s permissions; when Jira explains a 403 you see jira rejected the request: with Jira’s own reason instead. If you made a new token and it is still not accepted, workflow doctor --online says which source the one it sent came from, and a jira.token in the file wins over the others (Where can each token come from, and which one wins?). See Configuration: Jira token (on-premises / Data Center).

workflow says “no Jira REST API answered at that address”. What is wrong?

The address answered 404 without a Jira error message, so jira.base_url reaches a web server but not Jira’s REST API. The usual cause is a missing context path: many on-premises instances live under one, such as https://jira.example.com/jira. The error quotes the address it tried. Open your Jira in a browser, copy the address up to (not including) /browse/..., put that in jira.base_url, and run workflow doctor --online again. See Configuration: Fields.

My Jira is behind single sign-on. How do I get through?

workflow never follows a redirect, so your token can’t be passed on to a sign-in page or a plain-http hop. If you see the server at https://jira.example.com redirected the request: refused to follow a redirect, or the answer was not JSON but "text/html" — does jira.base_url reach Jira itself rather than a sign-in page?, a gateway in front of Jira is answering instead of Jira. First check whether the API takes a token directly: curl -sik -H "Authorization: Bearer <token>" https://your-jira/rest/api/2/myself. If the gateway issues its own bearer token, as Azure AD Application Proxy does, have jira.token_command fetch it. If the gateway checks headers of its own, as Cloudflare Access does, add them under jira.headers, next to your Jira token; their values are masked wherever the configuration is shown. See Configuration: Jira behind single sign-on (Azure AD / Office 365, and others).

The Issues pane shows only issues assigned to me. How do I see a sprint, a team filter or unassigned work?

Name more lists under jira.views, each with a name and a jql, and press v to move between them; the first view is the one shown at start. A view whose JQL never mentions assignee is still limited to your own issues, so to see anyone else’s work, say whose in the query: for example assignee is EMPTY, assignee in membersOf("my-team"), or (assignee = currentUser() OR assignee is EMPTY). A view missing its name or jql is refused when the file loads. See Configuration: Issue views.

Why can’t I move an issue to the status I want?

Jira’s own workflow decides which statuses an issue can move to from where it stands, and workflow offers only those: Change status (t) lists them, and workflow transition matches the name you give against them, ignoring case. A status not offered is refused, naming the ones that are, as in no move of PROJ-7 to "Done" is offered from where it stands; it can move to In Progress, so you may need to pass through another status first. A move that asks for fields is refused on the command line with moving PROJ-7 to Done asks for Resolution; change its status with t in the interface or Change status on the web, whose forms ask for them. Both exit with status 4. See Using workflow: Pick up an issue.

Branches, commits and hooks

workflow does not recognize the issue in my branch name. Why?

A Jira key is read only as Jira writes it, in uppercase: a project key of two or more characters, a hyphen and a number, as in fix/PROJ-412-login. fix/proj-412-login names no issue. With jira.project set, only keys in that project count. Without it, common tokens shaped like keys (UTF, SHA, CVE, ISO, RFC, MD) are skipped, so fix/UTF-8-decoding names none. A forge issue number counts only at the start of the name or of a path segment, as in 42-fix-typo. For a branch named some other way, press i on the Branch pane to link it to an issue. The link is kept in git as branch.<name>.workflow-issue. Otherwise, name the issue in the pull request’s title or description. See Using workflow: Branch.

How do I change the branch names workflow proposes?

Set branch.template, branch.prefixes, branch.default_prefix and branch.slug_limit in .workflow.json. The default is {prefix}/{key}-{slug}: fix for a bug, feat for anything else, and a slug of the summary capped at 48 characters. The template must contain {key}, because workflow reads the issue back out of the name. A template without it is refused when the file loads, with invalid branch template: must contain {key}. prefixes replaces the built-in map rather than adding to it, so a type you leave out takes default_prefix, not fix. You can still edit the proposed name before creating the branch. See Configuration: Branch names.

How do I work on two issues at once, or on an issue that already has a branch?

workflow branch KEY refuses to recreate a branch it would name the same, exits 4, and tells you how to go there, as in a branch for this issue already exists: fix/PROJ-7-login; switch to it with `git switch fix/PROJ-7-login` . To keep your current checkout and open another issue beside it, use a worktree. Run workflow branch KEY --worktree (or press ctrl+g in the terminal’s Start work overlay) and it creates a sibling directory named after the repository and the branch, such as ~/src/api-fix-PROJ-7-login. With --worktree, the last line of output is that directory alone, so cd "$(workflow branch PROJ-7 --fetch --worktree --yes | tail -n 1)" works. A worktree whose directory you deleted shows as (worktree gone), and git worktree prune frees its branch. See Using workflow: Branch.

Does workflow enforce Conventional Commits?

The commit composer (c on the Commits pane) builds the subject from its parts, so what it writes is always a well-formed Conventional Commit. ←/→ choose the type, tab moves to the scope and the description, a ruler counts against the subject limit, and ctrl+x marks a breaking change. It also adds a trailer naming the issue unless the body already has one. The defaults are the built-in types, a 72-character subject and a Refs: trailer. Change them with commit.types, commit.subject_limit and commit.refs_trailer, and pre-fill a scope with commit.default_scope. A branch whose prefix is one of the types opens the composer on that type. A commit you make with plain git commit is checked only by your repository’s own hooks. See Using workflow: Stage and commit and the commit.* rows of Configuration: Fields.

A git hook failed when I committed. What now?

workflow runs plain git commit, so your repository’s hooks run exactly as they would in a terminal, and their output streams into the detail pane. workflow has no way to skip them. When a hook fails, it lists every file:line the hook reported. Pick one and press enter to open your editor there, fix it, then press r to run the commit again. Your commit message is kept. A place that names no file from the repository’s root opens nothing and says so. o toggles the full output, and esc leaves and reads the repository again. With lefthook installed, you can try a hook without committing: press h on the Commits pane and choose it (pre-commit comes first). lefthook skips a pre-commit job when nothing is staged, so stage something first. See Using workflow: When a hook fails.

Why doesn’t workflow offer to set up lefthook for my existing git hooks?

It offers only when all three are true: lefthook is installed and on your PATH, .git/hooks holds a runnable hook that is not lefthook’s own, and the repository has no lefthook configuration yet (a lefthook.yml, .lefthook.yml or .config/lefthook.yml, in any format lefthook reads). The Commits pane then says A hook is not managed by lefthook. Press g to set up lefthook. In the offer, enter turns plain-command hooks into lefthook jobs, v keeps every hook whole as a script under .lefthook/, and esc skips it. Either choice writes lefthook.yml and runs lefthook install, which keeps the old hooks as .git/hooks/*.old. An existing lefthook.yml is never overwritten. If the install fails, fix the cause and run lefthook install yourself. See Using workflow: Existing git hooks.

Git forge (GitHub and GitLab)

Where does workflow get my GitHub or GitLab token, when it says “no forge token found”?

It checks three places, in this order: an environment variable ($GITHUB_TOKEN or $GH_TOKEN for github.com, $GITLAB_TOKEN or $GLAB_TOKEN for gitlab.com), then gh auth token if gh is installed and signed in to that host, then forge.token. If you already ran gh auth login, there is nothing to set. Depending on where you meet it, the message reads no forge token found: set $GITHUB_TOKEN, run `gh auth login`, or set forge.token (for github.com) or no forge token was found; for GitHub set $GITHUB_TOKEN or sign in with gh, for GitLab set $GITLAB_TOKEN, or set forge.token. workflow doctor --online shows where the token came from (token from the environment, token from the forge CLI or token from forge.token), or what it would read for this repository’s host. workflow never reads glab’s login: set $GITLAB_TOKEN (with $GITLAB_HOST, or $GL_HOST, on a self-managed GitLab) or forge.token, or set forge.cli: true so requests go through glab itself. See Configuration: The forge token you probably do not need.

How do I use GitHub Enterprise Server or a self-managed GitLab?

When doctor says the host is not github.com, a ghe.com tenant or gitlab.com, workflow cannot tell from the git remote which forge it is: git.example.com could be either GitHub or GitLab, so tell it: "forge": { "kind": "github", "host": "git.example.com" } (or "gitlab"). forge.kind set without forge.host is reported as incomplete. Each token is used only for the host it belongs to. For a GitHub Enterprise Server, set GH_HOST=git.example.com with $GH_ENTERPRISE_TOKEN, or run gh auth login --hostname git.example.com, or set forge.token next to forge.host. A self-managed GitLab reads $GITLAB_TOKEN when $GITLAB_HOST names it. github.com, *.ghe.com tenants and gitlab.com need no forge.kind; on those it is ignored. For an internal certificate, see A request fails with “could not reach”, or names a certificate. What do I check?. See Configuration: On-premises forges need forge.kind and forge.host.

What permissions does the forge token need, when opening or merging says the forge “refused this”?

A read-only token still shows issues, pull requests, reviews and CI, but every write is refused, with a message such as GitHub refused this: the token may lack the repo scope or the fine-grained permission this needs, or your role may not allow it. followed by the forge’s own reason. On GitHub, a classic token needs repo (gh auth login grants it). A fine-grained token needs read and write on Pull requests, Issues, Contents and Actions, plus read on Metadata, Commit statuses and Checks. On GitLab the token needs the api scope, not just read_api, plus a role that allows the action (Developer to open a merge request). workflow doctor --online warns can read but not write: it has read_api, not api when GitLab says what the token may do. Pushing is never the token’s job: git pushes with its own credentials. See Configuration: What the token needs to be allowed.

I work from a fork. Why does the pull request go to the wrong repository, or to my fork?

workflow asks git which remote is the base and doesn’t keep a setting of its own. The base is the remote gh repo set-default marked (remote.<name>.gh-resolved = base), else a remote named upstream, else origin. So in a fork’s clone, add the original repository as upstream, or run gh repo set-default. The pull request opens on the base repository, from your branch on the push remote, and its CI, reviews and issues are read there too. workflow doctor and the Repositories pane show the base next to origin. See Using workflow: Working from a fork.

Why isn’t my pull request template used?

workflow looks for templates where the forge does. On GitHub it reads a pull_request_template.md (or .txt, any case) in .github/, the repository root or docs/, or a pull_request_template/ directory of them in those places; on GitLab it reads .gitlab/merge_request_templates/, putting Default first. Templates are read from your checkout, so a template only on another branch isn’t seen, and on an on-premises host workflow must know the forge (forge.kind and forge.host) to look at all. A file larger than 64 KiB, or a link pointing outside the repository, is skipped. With several templates, the first form chooses between them and ctrl+t moves to the next; once you edit the description no template replaces it, and with no template the description lists the branch’s commits. See Using workflow: Open the pull request.

Why does CI show none?

CI is read for the branch’s open pull request, so it shows none on the base branch, on a branch with no pull request yet, and once the pull request has merged. It also shows none when nothing reports a check: no workflows or pipeline, or, on GitHub, a first-time contributor’s run waiting for a maintainer’s approval. If the forge refuses the read, workflow status still says none and names the failure on standard error; a fine-grained GitHub token needs read access to Commit statuses and Checks. GitHub’s CI is read at this clone’s branch head, so a fix pushed from another clone shows up once this branch moves to it. For an announcement waiting on CI, see Why hasn’t my announcement posted yet?; for the review queue, see What does workflow reviews list. See Using workflow: Limits.

What does workflow reviews list, and why are some reviews missing or their CI none?

It lists the open pull or merge requests that ask for your review, across every repository on the forge your current repository’s remote points at, longest-waiting first; --sort newest or --sort repo changes the order, and --json prints data. On GitHub it searches is:pr is:open review-requested:@me, so a pull request whose review request has been met or withdrawn drops out, and a fine-grained token sees only the repositories it was granted. GitHub’s search carries no CI, so that column always reads none there. On GitLab it lists open merge requests whose reviewers include you, with each one’s head pipeline. The web’s Reviews section and the terminal’s queue show the same list. See The web interface: Reviews.

Slack and other chat services

Should I use a Slack user token or an incoming webhook?

Use a webhook if you want the quickest setup. It takes about two minutes, has no scopes to grant and no token to keep fresh, but it only ever posts to the one channel it was made for: messaging.channel and --channel don’t apply, and it can’t tag anyone. A user token posts as you to any channel you’re in (messaging.channel, plus any in messaging.channels), and it is the only way to tag code owners and user groups. You can set up one or the other, not both. A file with both is refused when it loads, with Slack posts with a user token or a webhook_url, not both; remove one. See Configuration: Slack: a user token or a webhook.

How do I post to Teams, Discord or another chat service?

Create an incoming webhook in that service. Then set messaging.kind to teams, discord or webhook, and put the URL in messaging.webhook_url. The webhook carries its own destination, so messaging.channel doesn’t apply. Asking for another channel with --channel is refused with it takes effect only with a Slack user token; a webhook posts to the channel it is bound to (exit status 2). Treat the URL like a password: anyone who has it can post there. workflow doctor --online can’t check a webhook without posting to it, so it reports the webhook as unchecked, not as failed. See Configuration: Teams, Discord or a plain webhook.

What does workflow slack login ask for, and where do I find each answer?

It asks three questions, in this order:

Slack app client ID (Basic Information):
Slack app client secret (not shown):
Refresh token, xoxe-1-… (not shown):

The client ID and secret are on your app’s Basic Information page. No app yet? See I have an app configuration token for the manifest route. The refresh token (xoxe-1-…) appears under OAuth & Permissions once the app is installed with token rotation on. workflow refreshes the token once to check what you typed, keeps the new pair, writes messaging.client_id to the file, and prints Logged in to Slack as <you> in <workspace>. The token is kept in … and refreshed before it expires. It needs a terminal, and it needs a .workflow.json to exist first (workflow config init). See Configuration: Slack: a user token or a webhook.

I have an app configuration token (from “Your App Configuration Tokens”). Can workflow use it?

Not to log in or post. An app configuration token only manages apps, through Slack’s App Manifest API, and it lasts twelve hours. The refresh token generated with it starts xoxe-1- too, but it isn’t the one workflow slack login asks for either. What the configuration token can do is create the app from a manifest, in place of creating the app, adding its scopes and turning on token rotation by hand. That app then gives you the client ID, client secret and refresh token workflow slack login asks for. It acts as you, with no more right than you have: if your workspace approves apps before they are installed, installing it is still a request to your administrators. The steps are in Configuration: Creating the app from a manifest.

workflow slack login refuses because of a webhook or messaging.kind. What do I do?

A user token can’t sit alongside a webhook, and only Slack has user tokens. If you see slack posts through messaging.webhook_url here; remove it first, since Slack posts with a user token or a webhook, not both, delete messaging.webhook_url from the file and run the login again. If you see messaging.kind is not slack; a user token is Slack's alone, set messaging.kind to slack (or leave it empty, which means Slack). If you see no .workflow.json to log in with; run `workflow config init` first, create the file first. All three exit with status 3, the configuration family. See Scripting: Exit status.

workflow slack login says it answered a “bot” token. What did I do wrong?

refreshing gave no Slack user token: it answered a "bot" token means the refresh token you typed belongs to the app’s bot token, not its user token. workflow only posts with a user token. Add the chat:write scope under User Token Scopes (not Bot Token Scopes), reinstall the app, and give workflow slack login the user refresh token. See Configuration: Slack: a user token or a webhook.

Does the Slack token really expire every twelve hours? Do I have to log in again?

The access token does; you don’t log in again. With token rotation on, each access token (xoxe.xoxp-…) lasts twelve hours, and its refresh token (xoxe-1-…) swaps it for a new pair exactly once. Before every post, workflow checks the token. If it has less than ten minutes left, workflow refreshes it, and it refreshes once more if Slack still calls it expired. Each new pair is kept where the first one was. workflow doctor --online refreshes the token if it is due, and says whose it is, where it is kept and when it expires. You only log in again if the chain breaks (Posting fails with invalid_refresh_token). See Configuration: Slack: a user token or a webhook.

Posting fails with refreshing the Slack user token was refused: invalid_refresh_token. How do I recover?

A Slack refresh token works only once. If it was spent somewhere else, the copy workflow holds is dead. That happens when the same .workflow.json or keychain entry is used on two machines, when you tried the refresh token yourself (with curl, say) before logging in, or when a new pair couldn’t be saved (slack gave a new token that could not be kept; run `workflow slack login` again). To recover, reinstall the app under OAuth & Permissions to get a fresh refresh token, then run workflow slack login again. Don’t copy the token’s file or keychain entry to another machine, because each refresh spends the token the other copy holds. A refusal like this exits with status 3. See Configuration: Slack: a user token or a webhook.

Posting says another workflow is refreshing the Slack token. What do I do?

another workflow is refreshing the Slack token; try again in a minute means another workflow on this machine (say, the terminal and --web running at once) held the refresh lock for more than ten seconds. The lock is slack-refresh.lock, next to the store. The operating system releases the lock when the program holding it exits, so a lock left behind by a crash never blocks you. Just try again. See Configuration: Slack: a user token or a webhook.

Where is my Slack token kept?

On macOS, it is kept in the login keychain as a generic password named workflow-slack, so the configuration file holds no Slack secret. To check it’s there, run security find-generic-password -s workflow-slack. On Linux and Windows, where workflow drives no keychain (Does workflow use the keychain on Linux or Windows?), it is kept in the configuration file (your home .workflow.json unless another file already holds it). workflow rewrites messaging.refresh_token, messaging.access_token and messaging.expires_at after every refresh, and writes the file readable only by you. If the file on macOS already holds the token’s secrets, they stay in the file. workflow doctor --online prints which place is in use. See Configuration: Slack: a user token or a webhook.

What is WORKFLOW_SLACK_API for?

It points every Slack request that carries the user token at another address than https://slack.com/api, such as a fake Slack in a test or a proxy in front of Slack. It is only an environment variable, never a .workflow.json key, so a repository’s file can’t send your token anywhere. It takes an https:// address, or http:// only to 127.0.0.1, ::1 or localhost. Anything else is refused with WORKFLOW_SLACK_API must be an https:// address, or http:// to this machine (127.0.0.1, ::1 or localhost), and nothing is sent. Webhooks ignore it, since a webhook carries its own address. See Configuration: Slack: a user token or a webhook.

Announcements

Why hasn’t my announcement posted yet?

If you pressed w (or the pull request was armed when it opened, or you ran announce --when-green or pr --announce), the announcement waits until CI has passed. Passed means every check finished as success, neutral or skipped. Running checks keep it waiting. A failed check keeps it waiting for a later run that passes, and the pane says a check failed; waiting on a fix. With no checks at all, it says no checks reported yet. After ten minutes with none, it gives up: the terminal and the web say no checks reported on … within 10m; announce it by hand, and workflow announce --when-green says no checks were reported on … within 10m; if the repository has no CI, announce it by hand with workflow announce. Announcing now (enter, or plain workflow announce) is allowed while CI is running or reports nothing, but never while a check has failed or when CI can’t be read. x on the messaging pane stops the wait, and p, which its footer names announce now instead while one waits (Announce now instead on the web, below Stop waiting), previews it to announce now in its place. A draft is never armed (How do I stop workflow from announcing every pull request it opens?), and quitting while it waits drops it (What happens to a change I made if I close the tab, or quit workflow?). A webhook posts only to the channel it was made for, whatever messaging.channel says (Should I use a Slack user token or an incoming webhook?). See Using workflow: Announce it.

workflow announce refuses with a check failed on …. How do I announce anyway?

You can’t while a check has failed. workflow reads the CI again at the moment it posts, so a stale green can’t let a failure through, and a CI it can’t read blocks the post too. For a pull request that’s ready for review, the refusal ends so it was not announced; workflow announce --when-green announces it once a run passes: push a fix, or re-run the checks, and let it wait. For a merged pull request, there is no CI to wait for, so it says re-run the failed checks on the forge, then announce it once they pass. The refusal exits with status 4. See Using workflow: Announce it.

How do I stop workflow from announcing every pull request it opens?

Set "auto_announce": false under messaging. It’s on by default: when the terminal or --web opens a pull request that is ready for review, the announcement is armed to post once CI passes. A draft is never armed; instead you see a draft is not announced; announce it once it is ready. Turning it off is useful in a repository with no CI. With it off, you can still ask for a wait yourself with w, Announce when CI passes on the web, workflow announce --when-green or workflow pr --announce. See Configuration: Fields.

Why does the merged announcement show up as a new message instead of a reply in the thread?

workflow posts each announcement as a message of its own, first the ready-for-review one and then the merged one. It does this with a webhook and with a user token alike, and it never replies in a thread. That’s how it works, not something wrong with your setup. See Using workflow: Announce it.

How do I turn on tagging in announcements?

Tagging code owners and user groups needs four optional user token scopes on top of chat:write: users:read, channels:read, groups:read and usergroups:read. Without them, the post still goes out, just untagged. workflow announce warns Not tagging anyone: the Slack token lacks the <scope> scope; add it to the Slack app, then run workflow slack login. The terminal’s preview shows tagging needs the <scope> scope; this posts untagged. When only usergroups:read is missing, it shows tagging groups needs the usergroups:read scope and still tags the people it can. A token issued before you added the scopes won’t have them: reinstall the app and run workflow slack login again. Only the ready-for-review announcement tags anyone, and only with a user token. See Using workflow: Tag the code owners.

The terminal interface

How do I find out what a key does?

Press ? on any pane. The help lists every key, grouped by where it works: Moving around, each pane under its own name, In a composer or preview, Writing a comment, While a command runs, On every pane, and On panes and in overlays. It shows your ui.keys changes, not the defaults. Press ? again or esc to close it, and q there quits. The bottom row names the keys for where you are. On a narrow terminal it drops first the keys that only move, then the pane’s verbs, and ? and the way out go last. The full table is in Using workflow: Keys.

How do I rebind a key, and why does workflow refuse to start after I did?

Map an action to one key under ui.keys, for example "ui": { "keys": { "commit": "C", "comment": "ctrl+e" } }. An action you leave out keeps its default, and the change reaches the --web page too. workflow refuses to start, and workflow doctor reports why, when it cannot honor the map. The messages include ui.keys names a key action that does not exist: "run-pre-commit" (that action is now run-hook), ui.keys binds two actions to one key in the same context, and ui.keys moves interrupt onto a key that types or edits text. Fix the entry it names and start again. The list of actions and the rules are in Configuration: Rebinding keys.

Does workflow work in a small terminal, such as 80x24?

Yes. There is no minimum size; the layout adapts. From 80 columns up, the pane list (the rail) and the detail sit side by side. Below 80 columns they take turns: on the Issues pane, enter reads the selected issue in full and esc goes back to the list. The detail drops its border once the detail itself is under 60 columns, which at 80 columns it already is beside the rail. Below 60 columns, a dialog becomes a sheet over everything between the top row and the bottom row. Below 60 columns or 24 rows, the top row shrinks to each stage’s initial and glyph. See Using workflow: The screen.

The borders show as boxes or question marks, or I want no colors or animation. What do I set?

Set "ui": { "ascii": true } for a font or terminal that cannot draw the box-drawing borders and glyphs. workflow cannot detect this itself, so it is a setting. To turn off the colors but keep bold, faint and reverse video, set "ui": { "color": "never" } or set the NO_COLOR environment variable to a non-empty value. The glyphs carry the same meaning by shape. Set "ui": { "motion": "reduce" } to keep the spinner and the progress rules still. ui.color accepts only empty or never, and ui.motion only empty or reduce. A value such as "off" is refused when the file loads, with invalid ui setting: color is empty or never: "off". See Configuration: The interface: mouse, ASCII and color.

I can’t select text with my mouse in the terminal. How do I fix that?

While workflow captures the mouse for clicks and the wheel, your terminal’s own click-and-drag selection stops working. Press m to turn mouse capture off for this session, or set "ui": { "mouse": false } to keep it off. The mouse is on by default. See Using workflow: Mouse and ASCII.

I use a screen reader. Can the forms ask one question at a time?

Yes. Set "ui": { "accessible": true }, or set the ACCESSIBLE environment variable to a non-empty value. Forms then take the whole terminal, the way $EDITOR does, and ask one plain question at a time instead of being drawn over the screen. This covers status changes, the first-run questions, Settings, and opening a pull request. On the web page, the single-key shortcuts stay off until you turn on ui.web_shortcuts, so a key press never acts on its own; ? and the ctrl+k palette work either way. See Configuration: Accessible forms and Configuration: Keys on the web.

Why does the top row show a stage as not started?

The top row (Issue, Branch, Commits, Review, and your messaging service) is worked out fresh every time from the repository and the services, never stored. So it cannot drift from what git and the forge say, and it is worked out the same way in the terminal, in workflow status and on --web. The Issue stage is done once the branch names an issue, by a link you made with i or by its name; a pull request that names the issue does not count here. Until then it reads in flight while an issue is picked: selected in the terminal’s Issues list, or open on --web with no branch yet. The Branch stage is done on any branch other than the base. Commits is done once a feature branch has commits, and in flight before that while the feature branch holds changes not yet committed; on the base branch it reads as not started whatever the working tree holds, since no branch has been made for those changes. Review follows the pull request and its CI. The only thing remembered is whether the pull request was already announced, which the store keeps. On the base branch with no issue picked, Issue, Branch and Commits read as not started, clean tree or not. On a branch whose name names no issue and that has no link, Issue is never done. That is expected, not an error. See Using workflow: The screen.

How do I switch to another repository, or keep a list of the ones I use?

Open the Repositories pane (9). It lists where you are, the repository’s worktrees and your favorites. s switches to the row under the cursor, g opens a picker to browse to any directory (../web, ~/src/web), and f adds or removes a favorite. Every switch asks Switch to DIR? first, then opens workflow again in that directory with its own repository, forge and configuration files. Favorites are kept in kept.db by path alone, so with store.disabled there are none. From a script, workflow repositories --json prints the same list. See Using workflow: Repositories.

Why does the wrong editor open, or my commit message come back empty because the editor returned at once?

workflow picks the editor in git’s order: $GIT_EDITOR, then $VISUAL, then $EDITOR, and finally vi (notepad on Windows). git’s core.editor setting is not read, so set one of those variables. A GUI editor that returns before you close the file needs to be told to wait, such as EDITOR="code --wait". Anything below the scissors line (>8) is help text and is not kept. See Using workflow: Editor.

The web interface

How do I start the web interface, and what if the port is already in use?

Run workflow --web inside a repository and open the address it prints on stderr: workflow web: serving http://127.0.0.1:13579/#session=… — press Ctrl+C to stop. If another program already holds port 13579, it prints no address. It fails with an error such as serving the web API: listen tcp 127.0.0.1:13579: bind: address already in use. Pick another port with workflow --web --port 7001. --port takes 1 to 65535 and works only with --web; otherwise you get invalid argument "7001" for "--port" flag: it takes effect only with --web. ctrl+c stops the server. See The web interface: Start it.

The page says “workflow refused this page: it holds no session of the running server”. What do I do?

Open the exact address workflow --web printed when it started, including the #session=… part. Each run makes a new session, so a bookmark or tab from an earlier run stops working once the server restarts. The page keeps the session in this browser for that address and removes it from the address bar, so later tabs at the same address work. localhost in place of 127.0.0.1, or another port, is a different origin and does not have the stored session. Treat the printed address as a password while the server runs. See The web interface: Start it.

Can I open the web interface from another machine?

Not directly, and this is deliberate. The server listens on 127.0.0.1 only. It refuses any request whose Host does not name the loopback interface (the web interface serves the loopback interface only), refuses writes from a page served anywhere else (cross-origin writes are refused), and answers only requests that present the run’s session. Together these keep a website open in another tab, or another account on the machine, from driving your repository and tokens. If workflow runs on a remote host you reach over SSH, forward the port (ssh -L 13579:127.0.0.1:13579 <host>) and open the address it printed in your local browser. See The web interface: Start it.

What happens to a change I made if I close the tab, or quit workflow?

Closing or reloading a tab stops only the reads that page was waiting on. A write it already sent, such as a commit, a push, a post or a status change, keeps running to its end. Pressing ctrl+c in the terminal stops the server, and anything still in flight is cut short. In the terminal interface, quitting stops reads but waits for your writes to land, showing finishing 2 writes…, and gives up after timing.request_timeout. An announcement waiting for CI is not sent yet, so quitting first drops it. See The web interface: Start it and Using workflow: Limits.

Which browsers does the web interface support?

The latest two versions of Chrome, Edge, Firefox and desktop Safari. The page is only ever served to a browser on the same machine, so mobile Safari is not a target. The end-to-end suite runs every spec in Chromium, Firefox and WebKit, Safari’s engine. The page fetches nothing from the internet: its font and code come from the workflow binary. See The web interface: The page.

Taskwarrior

Which Taskwarrior do I need, and where do I get it?

workflow needs Taskwarrior 3.5.0 or newer. It is optional: without it, the Tasks pane and the web’s Tasks section say so and nothing else changes. On macOS, run brew install task. On Linux, use your distribution’s package if it has 3.5.0 or newer (many still ship 2.x). Otherwise build it from Taskwarrior’s source, which needs Rust and CMake. Then run it once in a terminal so it writes its configuration. An older one is reported, not used: the interface says “Taskwarrior is too old: 3.5.0 or newer is needed”, and workflow doctor names the version and path it found, such as taskwarrior is older than workflow needs: 2.6.2 at /usr/bin/task; 3.5.0 or newer is needed. See Install: What it needs.

doctor says task is not Taskwarrior, or it was never run. What now?

Taskwarrior’s program and go-task, the Taskfile runner, are both called task. workflow asks each task on PATH for its version and keeps the first that answers as Taskwarrior 3.5.0 or newer. If none does, doctor says task on PATH is not Taskwarrior (go-task?); set taskwarrior.program to Taskwarrior’s absolute path in ~/.workflow.json, for example "taskwarrior": { "program": "/opt/homebrew/bin/task" }. A repository’s file may not set it (taskwarrior.program: only the home directory's file may set it), and a relative path such as bin/task is refused. installed but never run means Taskwarrior has no taskrc yet: run it once in a terminal, by the path doctor names, and answer its question. Either change applies the next time workflow starts or switches directory. See Configuration: Finding it.

What does workflow do to my tasks, and how do I turn it off?

workflow runs task for every read and write and keeps no copy of your tasks. Reads run with Taskwarrior’s hooks off, so listing never fires a sync hook or changes a task. A task changes only when you press a key or accept an offer, such as starting the issue’s task when you create its branch. Writes run your hooks as task would in a shell. Tasks it creates carry jiraid, jiraurl and +jira. For task jiraid:PROJ-412 to work in your own shell, add uda.jiraid.type=string and the other three UDA lines that workflow doctor names to your taskrc. To turn the integration off even with Taskwarrior installed, set "taskwarrior": { "disabled": true }; the Tasks pane then says “Turned off by taskwarrior.disabled.” See Configuration: Reads, writes and contexts and Configuration: Tasks and issues.

Scripting and automation

In CI or cron, a command stopped with “no terminal to answer on”. What do I do?

The full message is no terminal to answer on; pass --yes to go ahead without asking. Every write (branch, pr, finish, announce, comment, transition, assign, worklog, summary --post, db-clean) prints a preview and then asks [y/N]. When standard input is piped or closed, as in CI or cron, nothing can answer, so the command stops and exits 2. Pass --yes (-y) to go ahead without asking, or --dry-run (-n) to see what it would do. comment reads its text from stdin, so piped it always needs --yes. Three commands differ: the guided config init says to pass --template, slack login must run at a terminal because it asks for secrets, and announce at a moment already announced says to run it at a terminal to be asked again. In CI, supply tokens through the environment (Where can each token come from, and which one wins?). Don’t copy a Slack user token to a CI machine: each refresh spends the copy the other machine holds (Posting fails with invalid_refresh_token), so a webhook suits CI. See Scripting: Writing without a person.

What exit statuses does workflow use, and where are they listed?

A script should tell failures apart by the exit status, never by the message, which is prose and may change. 0 is success, 1 any other failure, 2 a usage mistake (a bad flag, a wrong argument, a question with no terminal to answer it), 3 configuration (fix the file, a credential or a login), 4 a refused precondition (a pull request already open, a branch that already exists, a dirty tree), 5 a service that could not be reached or asked you to wait, and 130 Ctrl+C. When a command finds several problems at once, as doctor does, it exits with the first family in the order 2, 3, 4, 5, 1. So doctor with no configuration and no git exits 3. The full table, with examples for each status and the matching --web problem codes, is in Scripting: Exit status.

How do I get output a script can parse, and why is some text missing from my pipe?

Standard output carries only the result: the JSON, the preview, the rows, and what a write created. Everything said about it goes to standard error: notes, WARN and ERROR lines, dry-run lines and the questions. So workflow reviews | wc -l counts reviews and nothing else, and you need 2> to capture the messages. --json (-j) works on status, reviews, repositories, summary, issues, issue, doctor and pr, and config show always prints JSON with credentials masked. To change what standard error says, use --quiet (-q) for only warnings, errors and questions, --verbose for a DEBUG line per request, or --log-format json or logfmt for a log collector. --quiet with --verbose exits 2, and NO_COLOR turns the colored tags off. See Scripting: Standard output and standard error and Scripting: JSON.

Local data and privacy

Where does workflow keep its local data, and does it store my tokens?

It keeps two small SQLite files in your platform’s data directory: ~/Library/Application Support/workflow on macOS, $XDG_STATE_HOME/workflow or ~/.local/state/workflow on Linux, and %AppData%\workflow on Windows. workflow.db is a cache (the last commit scope, what you have announced, the last issue list of each view) that a session rebuilds. kept.db holds what you decided: whom each code owner is on Slack, the Slack user groups each repository offers, and your favorite directories. The store never holds a secret and is never keyed by a credential or by a raw remote or Jira URL. Tokens live in .workflow.json, the keychain or your environment (Where can each token come from, and which one wins?). Where the filesystem keeps Unix modes, each file is 0600 in a 0700 directory. workflow doctor names the directory on its Store row. Set "store": { "disabled": true } to keep nothing on disk. See Configuration: What is kept between sessions.

How do I clear the local data, and what does db-clean remove?

workflow db-clean prints Local data in DIR, each database file with its size and what it holds, and then asks before removing anything. By default it removes only the cache, workflow.db, which the next session makes again. --all also removes kept.db, after a warning, and workflow then asks again whom each code owner is on Slack. --dry-run only says what it would remove, and --yes removes without asking. A file another program holds open (another workflow session, on Windows) fails the clean with exit 4, and a symlink or directory where a database belongs is refused with exit 1. If workflow says the kept data was written by a build of workflow with another schema; run `workflow db-clean --all` to start it fresh, that is the fix. The same cleans are under Local data in the web’s Settings and behind L on the terminal’s Repositories pane (c removes the cache, C everything). See Using workflow: Cleaning the local data.

Does workflow send anything anywhere else, such as telemetry or update checks?

No. workflow makes no analytics, crash-reporting or phone-home calls, and does not check for updates: it talks only to the Jira, forge and messaging service you configure. The --web page fetches nothing from the internet either, since its font and code come from the binary. Code that sends data anywhere you did not configure is a security bug; report it as the security policy says.