A CLI for Linear built for AI agents. Written in Rust.
$ lql list --team PROD --state Todo --priority urgent
[3]{id,state,labels,title,priority,age,due,project}:
"PROD-587",Todo,tokamak,"Fix auth token refresh",1,14d,"Mar 28","Tokamak"
"PROD-612",Todo,backend,"Migrate database schema",1,2d,"Apr 01",""
"PROD-501",Todo,frontend,"Importar sesiones desde backup",1,30d,"overdue!",""
── 3 issues (3 todo)
Why this exists
I work with a coding agent (Claude Code) that interacts with Linear ~800 times a month. An analysis of 165 sessions revealed 500+ errors and 370+ retries caused by:
- Flags the agent kept inventing (
--statusinstead of--state,--priority urgentinstead of--priority 1) - Operations the existing CLI couldn't do (search, filter by project, assign project on create)
- Verbose output wasting context tokens (~70 tokens/issue in JSON vs ~25 in lql)
- Mandatory flags the agent kept forgetting (
--sort,--no-pager,--no-interactive)
Conservative estimate: 700K tokens/month wasted just fighting with the interface.
The solution wasn't better documentation. It was a better tool.
Design philosophy: the wrong path should be impossible, not forbidden
lql is built on a principle from adversarial programming: don't tell an AI agent what not to do — make it so the wrong thing can't happen.
Tolerance, not rejection
Instead of failing on reasonable input, lql normalizes it:
--status Done → --state completed (silent alias)
--state Todo → --state unstarted (with ℹ message)
--priority urgent → --priority 1 (with ℹ message)
--sort updated → --sort updatedAt (with ℹ message)
Instead of cryptic errors for invalid flags, lql suggests the right command:
$ lql list --filter backlog
✗ --filter does not exist. To filter by state: --state <state>. To search: lql search "text"
$ lql update PROD-42 --comment "fix applied"
✗ --comment does not exist in update. Use: lql comment PROD-42 "fix applied"
Sensible defaults, not mandatory flags
lql list # works. sorts by priority, active states, auto-detects team from cwd lql create "Fix auth bug" # works. auto-detects team, project, label from cwd
No --sort. No --no-pager. No --no-interactive. No --all-assignees. They're all defaults.
Token-efficient output
The primary consumer is an LLM reading the output. lql uses TOON (Token-Oriented Object Notation) — a compact format that encodes the schema once in a header, then uses positional values:
| Format | Tokens/issue | 50 issues |
|---|---|---|
| XML | ~70 | ~3,500 |
| JSON | ~50 | ~2,500 |
| TOON | ~25 | ~1,250 |
--json is available for scripts and pipelines.
Install
Homebrew (macOS & Linux)
brew tap frr149/tools brew install frr149/tools/lql
Shell installer (macOS & Linux)
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/frr149/lql/releases/latest/download/lql-installer.sh | sh
PowerShell installer (Windows)
powershell -ExecutionPolicy ByPass -c "irm https://github.com/frr149/lql/releases/latest/download/lql-installer.ps1 | iex"
Windows MSI
Download from GitHub Releases.
From source
cargo install --path . # or just install
Update
If you installed with the shell/PowerShell installer, lql includes a built-in updater:
lql-update
Authentication
You need a Linear API key. Generate one at linear.app/settings/api.
lql resolves the key from the first source that's set, in this order:
LINEAR_API_KEYenv var — the simplest path; works everywhere including CI.[auth].commandin~/.config/lql/config.toml— any command that prints the key to stdout.[auth].api_key_ref— shorthand for["op", "read", "<ref>"](1Password CLI users).
Option 1 — env var (recommended)
# Bash/Zsh export LINEAR_API_KEY=lin_api_xxxxxxxxxxxx # Fish set -gx LINEAR_API_KEY lin_api_xxxxxxxxxxxx
No config file needed — lql falls back to sensible defaults.
Option 2 — credential helper
If you'd rather not keep the key in your environment, configure any password manager that can print a secret to stdout:
# ~/.config/lql/config.toml [auth] command = ["pass", "show", "linear/api-key"] # command = ["op", "read", "op://<your-vault>/Linear/api-key"] # 1Password # command = ["bw", "get", "password", "Linear"] # Bitwarden # command = ["security", "find-generic-password", "-s", "linear", "-w"] # macOS keychain
Option 3 — 1Password sugar
For 1Password users, api_key_ref is a shorthand:
[auth] api_key_ref = "op://<your-vault>/Linear/api-key"
Equivalent to command = ["op", "read", "op://<your-vault>/Linear/api-key"].
Usage
# List issues (auto-detects team from cwd) lql list lql list --team PROD --state Todo,started --priority high lql list --overdue --all-teams # Create lql create "Fix auth token refresh" --project Tokamak --label tokamak --priority urgent lql create "Write migration guide" --due friday -d "Include rollback steps" # Update lql update PROD-42 --state Done lql update PROD-42 --priority urgent --label Bug # View lql view PROD-42 # Search lql search "auth token" --team PROD # Comment lql comment PROD-42 "Investigated — the issue is in the token refresh logic" echo "## Progress\nPartial fix deployed" | lql comment PROD-42 # Relations lql relate PROD-42 blocks PROD-43 lql relate PROD-42 blocked-by PROD-41 # Epics (Linear initiatives with a backing project) lql epic list lql epic create "Pre-locale ETL rework" --team PROD -d "Short summary" lql epic create "Pre-locale ETL rework" --description-file epic.md # long body lql epic view pre-locale-d9994a56fc60 # also exposes the backing project id/url lql epic add pre-locale-d9994a56fc60 PROD-42 PROD-43 # assign issues lql epic update pre-locale-d9994a56fc60 --description-file plan-v2.md # rewrite long body lql epic update pre-locale-d9994a56fc60 --summary "Now shipping in Q3" --target-date 2026-09-30 lql epic comment pre-locale-d9994a56fc60 "Phase 1 done — moving to ETL backfill" # Projects (the underlying Linear projects; useful when an epic has its own project surface) lql project view "Bastidor v1.0" # by name, slugId, or UUID lql project update <project-id> --description-file plan.md lql project comment <project-id> --file note.md # Diagnostics lql doctor # validate config, auth, API connectivity lql context # show resolved team/project/label for cwd lql labels # list all available labels # Raw GraphQL (escape hatch) lql raw '{ viewer { id name email } }'
When a label name exists in multiple teams, lql resolves it within the target team for create, update, and team-scoped list. This avoids sending a label ID from the wrong team.
Configuration
The config file is optional — lql runs with sensible defaults if LINEAR_API_KEY is exported. Use ~/.config/lql/config.toml to customize defaults, add a credential helper, or map directories to teams:
# [auth] is optional. See "Authentication" above for all three forms. [auth] command = ["op", "read", "op://<your-vault>/Linear/api-key"] [defaults] sort = "priority" states = ["backlog", "unstarted", "started"] limit = 50 [context-map] "~/projects/tokamak" = { team = "PROD", project = "Tokamak", label = "tokamak" } "~/code/myapp" = { team = "PROD", project = "MyApp", label = "myapp" } "~/code/tools" = { team = "TOOL" } [state-aliases] "Todo" = "unstarted" "In Progress" = "started" "Done" = "completed" "Canceled" = "canceled" "Cancelled" = "canceled" [priority-aliases] urgent = 1 high = 2 medium = 3 low = 4 none = 0 [retired-teams] TOK = "Tokamak issues are now in PROD. Use: --team PROD --label tokamak"
The context-map auto-detects team, project, and label from your working directory. No flags needed when you're inside a project.
Testing
just test # 264 unit tests (no API calls) just integration # 9 integration tests against real Linear API just all # both just lint # clippy with warnings as errors
The test suite includes:
- 75 ERR test specifications from the PRD (64 unit, 9 integration, 1 deferred, 1 out of scope)
- Property-based tests with proptest (any casing of "Todo" normalizes to "unstarted")
- Real API response fixtures (captured from Linear, never generated)
- Mock-based tests via
GraphQLClienttrait (no API calls in CI)
The methodology behind lql
lql was built using adversarial programming — a development methodology for AI-assisted coding where you assume your AI copilot will hallucinate APIs, invent flags, and take the most plausible-but-wrong path.
The key techniques:
- Schema-first development: Download the real API schema before writing any code. Never let the AI guess field names.
- Real fixtures, never generated: Every test fixture was captured from the real Linear API. The AI generates code against them, not the other way around.
- Two-layer validation (MDD): Layer 1 designs from anticipated errors (the PRD with 75 ERR specs). Layer 2 validates against real usage. Layer 1 hit rate: 80%.
- Tolerance contract: Adapt non-destructive input (normalize
--status→--state). Reject destructive input (invented labels). Always inform what was assumed. - Instruction elimination: A tolerant tool needs fewer instructions. The Claude Code skill for Linear went from 246 lines (150 of workarounds) to 205 lines with zero workarounds.
Read more
- The wrong path should be impossible, not forbidden — the core principle
- Linear Agent is not what you need — why we built lql instead of using Linear's built-in AI
- Adversarial programming: when your AI copilot invents the API — schema-first defense against hallucinated APIs
- 150 lines of apologies eliminated — how a tolerant tool erases defensive documentation
- Why my CLI doesn't speak XML: TOON and tokens — output format design for LLM consumers
- MDD: Don Quixote and Sancho Panza as AI copilots — the two-layer validation methodology
Cross-compile
just cross # builds for x86_64-unknown-linux-musl just deploy # cross-compiles and scps to server
License
MIT — see LICENSE.
Built with Rust and adversarial programming. By Fernando Rodriguez Romero.