Skip to main content

gtm-linear

Async-first Python SDK for the Linear GraphQL API. Thin, typed wrapper around httpx with optional sync support, Strawberry-typed models, explicit error semantics, and a bundled read-only CLI.

Status: Alpha (0.3.0, see pyproject.toml and CHANGELOG.md). API surface is small but incomplete — fall back to raw LinearClient.execute_async for anything not yet wrapped.


When to use this (agent triage)

Situation Use this SDK?
Read/write Linear issues from Python with typed responses Yes
Need ad-hoc GraphQL escape hatch alongside typed helpers Yes — LinearClient.execute_async(query, variables)
Building MCP-style tooling against Linear Yes (low-level), or prefer the official Linear MCP server for higher-level intent
Need full coverage of Linear's GraphQL schema No — only a focused subset of Linear types is wrapped today
Need webhooks, OAuth flow, or attachments No — not implemented
Writing a one-off shell command Yes — the bundled CLI: uvx gtm-linear issues --team ENG

If you only need to create or read a few issues from an automation, this is the right tool. If you need broad schema coverage, drop down to execute_async with a hand-written query.


Install

uv pip install gtm-linear        # from PyPI (>= 0.3.0)
# or, in this repo:
uv sync

Installing the package also installs the gtm-linear console command — see CLI.

Requires Python >=3.11. Runtime deps: httpx>=0.27, pydantic>=2.0, pydantic-settings>=2.14.2, typer>=0.27 (imported only by the CLI module — import gtm_linear never touches it). Typer is unconditional rather than a [cli] extra so uvx gtm-linear and uv tool install gtm-linear need no extra syntax — a deliberate install-footprint trade-off for library-only consumers. The optional [strawberry] extra (ships the generated schema mirror) adds strawberry-graphql>=0.328.0, which pulls graphql-core>=3.3,<3.4.


Auth

Linear personal API key. Format: lin_api_.... Pass the raw key as the Authorization header value (no Bearer prefix — Linear accepts the key directly).

export LINEAR_API_KEY=lin_api_xxx

The SDK does not read env vars on its own. Caller is responsible for passing api_key= to LinearClient. The key is stripped of surrounding whitespace at construction — a trailing newline from a secret store is harmless instead of an Illegal header value crash — and anything that is not a str/SecretStr (a LinearClient or LinearSettings object, say) raises TypeError immediately rather than failing later inside httpx. Blank or corrupted keys (embedded whitespace, control, or non-ASCII characters) raise ValueError at the same point.


CLI

The package ships a read-only CLI as the gtm-linear console command. It wraps LinearWorkflow, so the SDK's typed reads are available without writing any Python:

# from a checkout
uv run gtm-linear viewer

# one-off, no local install (PyPI >= 0.3.0 ships the executable)
uvx gtm-linear issues --team ENG --limit 10

# or install it as a tool
uv tool install gtm-linear
gtm-linear search "onboarding"

Auth uses the SDK's LinearSettings resolution: LINEAR_API_KEY (lin_api_...) from the environment or a .env / .env.local file in the working directory. Endpoint overrides (LINEAR_BASE_URL, LINEAR_TIMEOUT) are honored from the real environment only — a dotenv file may supply the key, but never redirect where it is sent. Real environment variables also take precedence over dotenv for the key itself; note that a dotenv file in the current directory is trusted for auth, so run the CLI from directories you control.

Command Purpose
gtm-linear viewer Auth check: print the user the API key belongs to
gtm-linear teams List teams (key, name, id)
gtm-linear issues --team ENG [--state open|all] [--limit N] [-v] List a team's issues, newest updated first (team key is case-insensitive; default state: open, limit: 25)
gtm-linear issue ENG-123 Fetch one issue by identifier (any casing) or Linear UUID
gtm-linear search "term" [--limit N] [-v] Free-text issue search across the workspace (multi-word terms may be unquoted — words are joined; dash-prefixed values are searched as-is, so a mistyped flag becomes the term)

Every command accepts --json for machine-readable output. --limit is validated to 1–100 at parse time; a full page prints a stderr note that more results exist. Exit codes: 0 success, 1 runtime failure (auth, API, not-found, network, a malformed LINEAR_BASE_URL, or an unexpected response shape — each printed as a single red error: … line on stderr, never a traceback; a closed output pipe, as in | head, exits 1 without printing an error), 130 Ctrl-C, 2 usage error. The CLI is deliberately read-only; writes stay in the SDK (LinearMutations) so a shell typo can never mutate Linear.


Mental model

Three classes, all importable from the package root:

LinearClient        # transport + auth + GraphQL execution
  ├── LinearQueries # typed read wrappers (get_issue, list_issues, search_issues, get_team, get_user)
  └── LinearMutations # typed write wrappers (issues and comments)
LinearWorkflow      # injected-key CLI facade over every typed read/write helper

LinearQueries and LinearMutations are stateless facades over a LinearClient. They do not own the client; they borrow it. Construct one client and pass it to both.

Pydantic models validate Linear response payloads and mutation inputs internally. Strawberry's Pydantic integration exposes those validated models as the public GraphQL types and inputs.

import asyncio
from gtm_linear import (
    IssueCreateInput,
    LinearClient,
    LinearMutations,
    LinearQueries,
    PaginationOrderBy,
)

async def main() -> None:
    async with LinearClient(api_key="lin_api_xxx") as client:
        queries = LinearQueries(client)
        mutations = LinearMutations(client)

        team = await queries.get_team_by_key("ENG")
        assert team is not None
        issue_filter = {"team": {"id": {"eq": team.id}}}
        issues = await queries.list_issues_page(
            issue_filter,
            first=20,
            order_by=PaginationOrderBy.updatedAt,
        )
        created = await mutations.create_issue(
            IssueCreateInput(title="Hello", teamId=team.id, description="from agent"),
        )

asyncio.run(main())

CLI and automation facade

For a command-line tool or automation that receives an API key from its own configuration or secret manager, use LinearWorkflow. It owns the client and provides matching synchronous and async methods; synchronous methods are for normal CLI entrypoints, while *_async methods are for an existing event loop.

from gtm_linear import IssueCreateInput, LinearWorkflow

with LinearWorkflow(injected_api_key) as linear:
    team = linear.get_team_by_key("ENG")
    assert team is not None
    open_issues = linear.list_open_team_issues(team.id)
    issue = linear.create_issue(
        IssueCreateInput(title="Investigate alert", team_id=team.id)
    )

list_open_team_issues excludes completed and canceled states and orders by most recent update. For ad-hoc GraphQL not yet represented by the typed API, access linear.client.execute(...) or use LinearClient directly.


Public API surface

Importable from gtm_linear:

Symbol Kind Purpose
LinearClient class Transport + auth + raw GraphQL execution
LinearQueries class Typed read helpers
LinearMutations class Typed write helpers
LinearWorkflow class Injected-key sync/async facade for CLI workflows
LinearAPIError exception Raised on HTTP non-200 OR GraphQL errors field present
LinearPaginationError exception Raised by iter_* when a connection stalls: several consecutive empty pages while hasNextPage stays true
Issue model Linear issue
Comment model Linear issue comment
IssueConnection model Paginated issue list (nodes, pageInfo)
IssueCreateInput input title, teamId, optional description, labelIds, priority, assigneeId, projectId, stateId
IssueUpdateInput input Optional title, description, labelIds, priority, assigneeId, projectId, stateId
Issue filter mapping dict[str, Any] Linear-shaped nested issue filter passed through to GraphQL
PaginationOrderBy enum Supported issue connection ordering (createdAt, updatedAt)
Team model id, name, key
TeamConnection model Paginated teams
User model id, name, email, active
UserConnection model Paginated users
Project model id, name, slug
ProjectConnection model Paginated projects
WorkflowState model Linear workflow state (id, name, type, color, position)
WorkflowStateConnection model Paginated workflow states
PageInfo model hasNextPage, hasPreviousPage, startCursor, endCursor

The public Strawberry types are backed by Pydantic models, so malformed API payloads and invalid mutation inputs fail validation before they are exposed to callers or sent to Linear.

IssueCreateInput and IssueUpdateInput are Strawberry input types backed by Pydantic models. Construct positionally or with kwargs; some static type checkers may flag the call signature — the scripts/smoke.py file demonstrates the working ignore pattern. Optional fields set to None are omitted from mutation variables.


LinearClient reference

LinearClient(api_key: str)

State:

  • BASE_URL = "https://api.linear.app/graphql" (class attribute, overrideable on subclasses or by monkeypatch in tests)
  • Lazily creates an httpx.Client (sync) and httpx.AsyncClient (async) on first use.
  • Connection reuse: both clients persist across calls until their respective close method or context-manager exit.

Methods

Method Sync/Async Returns Raises
execute(query, variables=None) sync dict[str, Any] — the data payload LinearAPIError
execute_async(query, variables=None) async dict[str, Any] — the data payload LinearAPIError
close() sync None RuntimeError if an async client is still open
aclose() async None —
__enter__ / __exit__ sync ctx mgr — —
__aenter__ / __aexit__ async ctx mgr — —

Error contract

execute / execute_async raise LinearAPIError if either:

  1. The response JSON contains a top-level errors key (GraphQL-level failure), OR
  2. The HTTP status is not 200, OR
  3. The response is not parseable JSON / not a dict.

LinearAPIError.errors is the raw list of error dicts from Linear; LinearAPIError.message is a human-readable summary. Inspect .errors to recover structured codes.

Return value

The methods strip the outer {"data": ...} envelope and return the inner dict. So for a query of query { viewer { id } }, you get back {"viewer": {"id": "..."}}.

Client pitfalls

  • Use close() for sync-only use and await aclose() for async-only use. close() raises RuntimeError if an async client remains open, so it cannot silently leak the async connection pool. async with closes both transports if they were created.
  • BASE_URL is the production Linear endpoint. There is no staging URL toggle.
  • The Authorization header is set to the raw API key string. Linear expects no Bearer prefix; do not add one.

LinearQueries reference

All methods are async. All accept Linear UUIDs unless noted.

Method Args Returns Notes
get_issue(issue_id) str Issue | None Returns None on not-found (not an error)
list_issues(team_id, first=50) str, int list[Issue] Compatibility helper for a team's first issue page
list_issues_page(filter=None, first=50, after=None, order_by=None, include_archived=False) dict[str, Any] | None, int, str | None, PaginationOrderBy | None, bool IssueConnection Root issue connection with cursor pagination
search_issues(term) str list[Issue] Backed by Linear's searchIssues GraphQL field
get_team(team_id) str Team | None UUID only; use get_team_by_key for ENG-style keys
get_team_by_key(key) str Team | None Resolves a human team key such as ENG
get_user(user_id) str User | None —
list_workflow_states(team_id, first=50) str, int list[WorkflowState] Convenience helper for a team's first workflow-state page
list_workflow_states_page(team_id, first=50, after=None, include_archived=False, order_by=None) str, int, str | None, bool, PaginationOrderBy | None WorkflowStateConnection Team-scoped workflow-state connection with cursor pagination

Team workflow states

Workflow states are queried directly from Linear's workflowStates connection and filtered by team ID. Use the page method when you need cursor metadata:

states = await queries.list_workflow_states_page(team.id, first=50)
for state in states.nodes:
    print(state.id, state.name, state.type)

if states.page_info.has_next_page:
    next_page = await queries.list_workflow_states_page(
        team.id,
        first=50,
        after=states.page_info.end_cursor,
    )

Team key → ID and filtered issue pages

Use get_team_by_key to resolve a human-facing team key, then query a cursor-aware connection. The following finds open issues in that team, ordered by their latest update:

from gtm_linear import (
    PaginationOrderBy,
)

team = await queries.get_team_by_key("ENG")
assert team is not None
issue_filter = {
    "team": {"id": {"eq": team.id}},
    "state": {"type": {"nin": ["completed", "canceled"]}},
}
page_size = 100
order_by = PaginationOrderBy.updatedAt
issues = await queries.list_issues_page(
    issue_filter,
    first=page_size,
    order_by=order_by,
)
for issue in issues.nodes:
    print(issue.identifier)

if issues.page_info.has_next_page:
    next_page = await queries.list_issues_page(
        issue_filter,
        first=page_size,
        after=issues.page_info.end_cursor,
        order_by=order_by,
    )

Issue shape returned by queries

Every issue method returns this projection:

Issue(
    id: str,
    title: str,
    description: str | None,
    identifier: str,       # e.g. "ENG-123" — the human-readable ID
    url: str,
    priority: int | None,  # 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low (Linear convention)
    status: str | None,    # state.name flattened — e.g. "In Progress"
    assignee: User | None,
)

status is a flattened string (the state's name), not the full Linear WorkflowState object. If you need state ID or color, use execute_async directly.


LinearMutations reference

Method Args Returns Raises
create_issue(input_) IssueCreateInput Issue (full) ValueError if API returns no issue; LinearAPIError on transport failure
update_issue(issue_id, update) str, IssueUpdateInput Issue (full) ValueError if API returns no issue; LinearAPIError on transport failure
delete_issue(issue_id) str bool (success flag) LinearAPIError on transport failure
create_comment(issue_id, body) str, str Comment (full) ValueError if API returns no comment; LinearAPIError on transport failure

Mutation pitfalls

  • IssueCreateInput and IssueUpdateInput omit fields set to None; values such as priority=0 and labelIds=[] are forwarded to Linear.
  • delete_issue returns Linear's success bool. A False return is not an exception — check it explicitly if you care.
  • create_issue and update_issue raise ValueError, not LinearAPIError, when the API responds 200 but with an empty issue. Catch both if you're wrapping.
  • create_comment returns a typed Comment with createdAt parsed as a timezone-aware datetime when Linear returns an ISO-8601 timestamp.

Sync vs async

The low-level typed wrappers (LinearQueries, LinearMutations) are async-only. For synchronous CLI code, prefer LinearWorkflow, which owns the client and runs its async methods safely. Its synchronous methods must not be called from an active event loop; use their *_async counterpart there.

To use the low-level wrappers from sync code, wrap them with asyncio.run:

import asyncio
from gtm_linear import LinearClient, LinearQueries

async def fetch() -> None:
    async with LinearClient(api_key="...") as client:
        return await LinearQueries(client).get_issue("iss-1")

issue = asyncio.run(fetch())

For sync-only use, drop down to LinearClient.execute(...) directly.


Error handling pattern

from gtm_linear import LinearAPIError, LinearClient

try:
    async with LinearClient(api_key=key) as client:
        data = await client.execute_async("query { viewer { id } }")
except LinearAPIError as exc:
    # Both transport and GraphQL errors land here.
    print(exc.message)
    for err in exc.errors:
        print(err.get("extensions", {}).get("code"), err.get("message"))

Common Linear error codes worth branching on (found in errors[].extensions.code):

  • AUTHENTICATION_ERROR — bad / missing API key
  • FORBIDDEN — key lacks scope for the operation
  • INVALID_INPUT — malformed mutation input
  • RATELIMITED — back off and retry

The SDK does not retry on rate limits. Implement back-off at the call site.


Live smoke test

Read-only by default. Use to verify auth, network, and basic schema access:

LINEAR_API_KEY=lin_api_xxx uv run python scripts/smoke.py --team-key ENG
# add --create to also create+delete a throwaway issue

The script exercises: viewer query, get_team_by_key, get_team, list_issues_page, search_issues, and optionally create_issue + delete_issue. Source: scripts/smoke.py.


Repository layout

sdk-python-linear/
├── gtm_linear/
│   ├── __init__.py           # public re-exports
│   ├── _generated/           # codegen output: Pydantic models + GraphQL documents
│   ├── _schema.py            # generated Strawberry mirror ([strawberry] extra)
│   ├── cli.py                # read-only CLI behind the gtm-linear command
│   ├── client.py             # LinearClient (httpx transport)
│   ├── exceptions.py         # LinearAPIError and friends
│   ├── models.py             # LinearModel base class
│   ├── mutations.py          # LinearMutations (async write helpers)
│   ├── pagination.py         # cursor-following paginate helpers
│   ├── queries.py            # LinearQueries (async read helpers)
│   ├── settings.py           # LinearSettings (opt-in env/dotenv config)
│   └── workflow.py           # LinearWorkflow (sync/async facade)
├── tests/                    # respx-mocked suite (incl. test_cli.py)
├── scripts/                  # codegen toolchain + smoke.py (live API)
├── operations/               # GraphQL selection sets (codegen inputs)
├── schema/                   # Linear SDL pin (codegen input)
├── pyproject.toml            # hatchling; deps, extras, console scripts
├── CHANGELOG.md              # release notes
├── ruff.toml                 # lint config (repo-local, incl. per-file ignores)
├── pyrightconfig.json        # pyright config (repo-local)
├── pyrefly.toml              # type-checker config
├── .github/workflows/        # pypi.yml (publish), pullfrog.yml (agent harness)
├── .rwx/                     # RWX pipelines — ci.yml + dagger-ref-drift.yml (canonical CI)
└── .trunk/                   # lint config (trunk.io)

Build backend: hatchling. Wheel packages: ["gtm_linear"].


Development

uv sync                       # install deps
uv run pytest                 # run tests (respx-mocked, no network)
uv run pytest tests/test_client.py::test_execute_sync_returns_data  # single test
uv run python scripts/gen_operations.py --check   # operations/*.graphql vs _spec.toml
uv run python scripts/codegen.py --check          # generated models vs pinned schema
trunk check --all             # lint + type check (local; run uv sync first)
trunk fmt                     # autoformat

CI runs on RWX: .rwx/ci.yml runs pytest, the two --check scripts, a lean-install task (the wheel must import without the [strawberry] extra), and a weekly scheduled schema-drift cron (pytest -m network); .rwx/dagger-ref-drift.yml checks the pinned Dagger publish-module ref weekly. GitHub Actions remains only for the tag-triggered PyPI publish (pypi.yml) and the Pullfrog agent harness (pullfrog.yml) — see .rwx/.migration-inventory.md for the port inventory and flip status. trunk check is not a CI gate.

Tests use respx to mock httpx — no network access required. pytest-asyncio is in auto mode, so async test functions don't need decoration.

Conventions

  • All public methods are documented with Google-style docstrings.
  • Never hand-edit gtm_linear/_generated/ or gtm_linear/_schema.py; regenerate with scripts/codegen.py (--check fails CI on drift). Both are lint-ignored in .trunk/trunk.yaml, so fixes belong in the generator.
  • Transport and API failures raise the typed hierarchy in gtm_linear/exceptions.py.
  • Input types are constructed positionally in tests and the smoke script; some checkers flag this (see # pyright: ignore[reportCallIssue] in scripts/smoke.py).
  • Tests intentionally use assert and _private internals; scoped per-file ignores in ruff.toml cover this, so don't add per-line noqas.
  • No retries, no connection pooling tuning, no logging. Add at the call site.

Known gaps (read before extending)

  1. Filtering coverage: Issue filters are plain dict[str, Any] mappings that mirror Linear's nested filter tree. Use execute_async for other Linear filters.
  2. Schema coverage: Only a focused subset of Linear resources is typed. Attachments, cycles, projects-as-containers, workflow mutations, and webhooks remain absent.
  3. Search filtering: search_issues accepts only a text term. Use list_issues_page for mapped team/state filtering.
  4. Subscriptions: Not supported. Linear's subscription API requires WebSockets — the client is HTTP-only.
  5. Status filtering: Workflow-state query results include the state ID and type. More advanced filters still require execute_async.

License

MIT. See LICENSE.

Metadata

Release files for gtm-linear 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for gtm-linear 0.3.0
File Size Uploaded
gtm_linear-0.3.0.tar.gz 307.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for gtm-linear 0.3.0
File Interpreter ABI Platform
gtm_linear-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 360.0 kB

Release files / gtm_linear-0.3.0.tar.gz

Download URL gtm_linear-0.3.0.tar.gz
Size 307.3 kB
Tags Source
SHA-256 checksum
How to use checksums
bba424e870c0d4812ebdf280c95ae60361aae9d7c947e090b3ea9385e740f57d
BLAKE2b-256 checksum
How to use checksums
84b9ba81544898f8877ab4488e840067485593d882e32339bcfd80ddf53086cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / gtm_linear-0.3.0-py3-none-any.whl

Download URL gtm_linear-0.3.0-py3-none-any.whl
Size 52.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bd758253399736433bfb997fff615d4d4f2fb8a38f24893cab51648dd4039728
BLAKE2b-256 checksum
How to use checksums
de97365e01b22be6767b7789a69dd7df0c1ce265039ce5705655df48832d4600
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page