Skip to content
Linear Adapter

Linear Adapter

The Linear adapter connects Sortie to Linear over a single GraphQL endpoint, POST https://api.linear.app/graphql. It is registered under kind "linear", fetches issues with Relay cursor pagination, and normalizes responses to the domain Issue and Comment types. Linear is a GraphQL API and reports application errors inside HTTP 200 bodies, so the adapter classifies a response by its top-level errors array before the HTTP status, unlike the REST trackers. The canonical API documentation is Linear Developers: GraphQL.

See also: WORKFLOW.md configuration for the full tracker schema, how to connect Sortie to Linear for setup instructions, error reference for all tracker error kinds, environment variables for $VAR expansion behavior.


Configuration

The adapter reads its configuration from the tracker section of the WORKFLOW.md front matter. Two fields are required; the rest have defaults.

FieldTypeRequiredDefaultDescription
kindstringYes-Must be "linear".
api_keystringYes-Linear personal API key. Sent verbatim in the Authorization header, no Bearer prefix. See authentication.
projectstringYes-Linear team key (e.g., ENG), the prefix on issue identifiers. Not a Linear project. See identifiers and team scoping.
endpointstringNohttps://api.linear.app/graphqlGraphQL endpoint URL. There is no self-hosted Linear; overriding serves tests and mocks.
active_stateslist of stringsNo["Backlog", "Todo", "In Progress"]Workflow-state names eligible for dispatch.
terminal_stateslist of stringsNo["Done", "Canceled", "Duplicate"]Workflow-state names that trigger workspace cleanup.
handoff_statestringNo(absent)Workflow-state name set after a successful agent run. Absent disables handoff.
query_filterstringNo""Raw Linear IssueFilter JSON fragment, ANDed with the team and state constraints. See query filter.
user_agentstringNo"sortie/dev"User-Agent header sent on all requests.

The adapter does not define an in_progress_state key. Dispatch-time transitions are a Jira and GitHub feature; the Linear config validation has no in_progress_state arm.

State names are compared case-insensitively at startup and resolved to the team’s canonical casing. active_states and terminal_states must not overlap, and handoff_state must appear in neither list. See state model.

tracker:
  kind: linear
  api_key: $SORTIE_LINEAR_API_KEY
  project: ENG
  query_filter: '{"labels": {"some": {"name": {"eq": "agent-ready"}}}}'
  active_states:
    - Backlog
    - Todo
    - In Progress
  handoff_state: In Review
  terminal_states:
    - Done
    - Canceled
    - Duplicate

api_key accepts $VAR indirection.


Authentication

The adapter authenticates with a Linear personal API key. The key is sent verbatim in the Authorization header with no Bearer prefix:

Authorization: <api_key>

The missing-prefix detail is the most common Linear integration bug. A Bearer-prefixed key returns HTTP 400 with a body message instructing the caller to remove the prefix. The adapter sends the key exactly as configured, so the value must be the bare key with no scheme and no surrounding whitespace.

Personal keys carry the lin_api_ prefix. Linear participates in GitHub secret scanning: a key committed to a public GitHub repository is detected and automatically revoked. A leaked key is dead.

Fixed headers on every request:

HeaderValue
AuthorizationThe api_key value, verbatim.
Content-Typeapplication/json
User-AgentConfigured user_agent value.

The HTTP client has a 30-second per-request timeout. Context cancellation propagates; a cancelled context aborts the in-flight request.

Construction-time validation

The constructor runs the viewer query to classify the key before the first poll cycle. A valid key returns the acting user on HTTP 200. An invalid, missing, or revoked key returns HTTP 401 with the body message Authentication required, not authenticated, mapped to tracker_auth_error, which blocks construction.

OAuth

OAuth 2.0 is not supported. The orchestrator runs as a single headless principal, and the 24-hour OAuth access-token expiry would require a token-refresh subsystem that personal API keys make unnecessary.


State model

Every Linear workflow state carries a workspace-immutable type category. There are seven values.

typeMeaningSuggested bucket
triageIntake queue awaiting acceptance (optional feature).Neither (excluded by default).
backlogAccepted, not planned.active_states
unstartedPlanned, not begun (e.g., “Todo”).active_states
startedWork in progress (e.g., “In Progress”, “In Review”).active_states / handoff_state
completedDone.terminal_states
canceledAbandoned.terminal_states
duplicateClosed as a duplicate.terminal_states

States are team-scoped. Two teams can each have an “In Progress” state with different UUIDs. A team can have several states of the same type (for example, “In Review” and “QA” are both started).

Name-based mapping

The adapter maps issues by configured state name, not by type. domain.Issue.State is issue.state.name with original casing preserved. The type category does not drive selection; it serves a startup tripwire that emits a WARN when a configured active_states entry resolves to a completed, canceled, or duplicate state, or a terminal_states entry resolves to a non-terminal state.

Canonical-casing preflight

Linear’s state.name.in filter is case-sensitive. At construction the adapter fetches the team’s states once, matches each configured name case-insensitively, and caches the team’s exact casing. Fetch queries send the canonical names. A configured name that no state on the team matches fails construction with tracker_payload_error (state "<name>" not found in team "<key>"); an unknown team key fails the same way (unknown team key "<key>").

Default mapping

A new Linear team ships with six states and no triage. The verified default mapping for that layout:

active_states: [Backlog, Todo, In Progress]
terminal_states: [Done, Canceled, Duplicate]
handoff_state: In Review   # operator-added started state; not a default

Identifiers and team scoping

Linear exposes three identifier-like values per issue.

ValueExampleProperties
issue.ida7c4f8e2-1b9d-4e3a-8f2c-6d5e4a3b2c1fUUID. Stable, globally unique.
issue.identifierENG-123Human-readable. Team key plus issue number.
issue.number123Numeric part. Unique only within a team.

The domain ID maps to issue.id; the domain Identifier maps to issue.identifier. The issue(id:) query accepts either the UUID or the human identifier. The adapter passes the form it holds and never constructs one form from the other.

tracker.project selects the Linear team key, not a Linear project. Workflow states are team-scoped, so the state model is well-defined only relative to one team. The team key is also the identifier prefix, which mirrors the Jira adapter where project is the issue-key prefix. Linear projects are cross-team containers that do not own states or identifiers. The team filter is team: { key: { eq: "<key>" } }; no team UUID resolution is needed for reads.


Field mapping

The adapter normalizes Linear GraphQL responses to domain.Issue fields.

Domain fieldLinear sourceNormalization
IDissue.idUUID string, as-is.
Identifierissue.identifierString, as-is (e.g., ENG-123).
Titleissue.titleString, as-is.
Descriptionissue.descriptionMarkdown. Null maps to empty string.
Priorityissue.priority0 (No priority) maps to nil. 1 (Urgent), 2 (High), 3 (Medium), 4 (Low) map to a non-nil *int.
Stateissue.state.nameString with original casing preserved.
BranchNameissue.branchNameOpaque string, as-is. The prefix is workspace-configurable; it is never parsed.
URLissue.urlString, as-is. Provided directly, not constructed.
Labelsissue.labels.nodes[].nameEach label lowercased. Non-nil empty slice when no labels.
Assigneeassignee.displayNameFallback to name, then email. Null assignee maps to empty string.
IssueType(not available)Always empty. Linear has no native issue-type field.
Parentissue.parent{id, identifier} to {ID, Identifier}. nil when absent.
CommentsSeparate connectionnil on candidate fetch. Populated by FetchIssueByID.
BlockedByissue.inverseRelations.nodesNodes where type == "blocks". See blocker extraction.
CreatedAtissue.createdAtISO-8601 timestamp string, as-is.
UpdatedAtissue.updatedAtISO-8601 timestamp string, as-is.

Candidates are sorted client-side by normalized priority ascending, then by creation time ascending. Issues with no priority sort last. The server sort hint is not trusted.

The nested labels and inverseRelations connections are capped at the first 25 nodes and are not paginated. An issue that exceeds the cap emits a WARN (nested connection truncated); the dropped nodes remain observable rather than silent.

Comment normalization

Domain fieldLinear sourceNormalization
IDcomment.idString, as-is.
Authorcomment.user.displayNameFallback to user.name, then botActor.name, else empty string.
Bodycomment.bodyMarkdown pass-through.
CreatedAtcomment.createdAtISO-8601 timestamp string, as-is.

Linear returns comments newest-first. The adapter re-sorts them ascending by creation time before returning.

Blocker extraction

BlockedBy is derived from the issue’s inverseRelations. When issue A blocks issue B, the relation appears in B’s inverseRelations as { type: "blocks", issue: A }. For each node whose type equals "blocks" (compared case-insensitively after trimming), a BlockerRef is produced:

FieldSource
IDnode.issue.id
Identifiernode.issue.identifier
Statenode.issue.state.name

Query filter

tracker.query_filter is a raw Linear IssueFilter written as a JSON object. The adapter merges it with the team and state constraints it sets internally; Linear ANDs sibling IssueFilter fields, so the result selects issues in the configured team, in the configured states, and matching the fragment.

# Issues carrying a label named "agent-ready"
query_filter: '{"labels": {"some": {"name": {"eq": "agent-ready"}}}}'

# Issues assigned to the API key's own user
query_filter: '{"assignee": {"isMe": {"eq": true}}}'

team and state are reserved keys. The adapter sets them from tracker.project and the configured state lists. A fragment containing either top-level key is rejected at construction with tracker_payload_error (tracker.query_filter must not contain a reserved key "team"; team is checked before state). A fragment that is not valid JSON, or is not a JSON object, is rejected the same way. The adapter does not validate field names; an unknown IssueFilter field surfaces on the first poll as a Linear argument-validation error.

The filter applies to FetchCandidateIssues and FetchIssuesByStates. It does not apply to the ID-based and identifier-based state lookups (FetchIssueStatesByIDs, FetchIssueStatesByIdentifiers), which use id and number connection filters; those issues already passed filtering at dispatch time.


Pagination

Linear uses Relay-style cursor connections. Every connection exposes pageInfo { hasNextPage endCursor }. The adapter requests with after: null, then after: endCursor, until hasNextPage is false.

PropertyValue
Page size (top-level connections)50
Page size (nested labels, inverseRelations)25, not paginated
first range1 to 250, both bounds enforced by Linear
CursorOpaque endCursor token, passed back verbatim. Never parsed or constructed.

When a connection reports hasNextPage: true but an empty or absent endCursor, the adapter returns tracker_missing_end_cursor rather than treating pagination as complete. Silent truncation would be a data-loss bug.


Rate limiting

Linear enforces a request budget and a complexity budget per API key.

  • Request budget: dynamic. Linear scales it by the number of paid seats in the workspace, so it is read from response headers and never hardcoded. A live single-seat workspace returned a limit of 2,500 against a documented 5,000.
  • Complexity budget: 3,000,000 points per hour, with a 10,000-point single-query cap. The production candidate query measures about 95 points.

Linear returns these headers on every response. Reset values are epoch milliseconds.

HeaderMeaning
x-ratelimit-requests-limitRequest quota (dynamic).
x-ratelimit-requests-remainingRequests left in the window.
x-ratelimit-requests-resetWindow reset time, epoch milliseconds.
x-complexityComplexity score of the query.
x-ratelimit-complexity-limitComplexity quota.
x-ratelimit-complexity-remainingComplexity left in the window.
x-ratelimit-complexity-resetWindow reset time, epoch milliseconds.
Retry-AfterSeconds to wait, present on rate-limit errors.

The adapter inspects x-ratelimit-requests-remaining, x-ratelimit-requests-reset, and Retry-After. It emits a WARN (rate limit exhausted) naming the reset time when x-ratelimit-requests-remaining reaches 0. It does not throttle client-side. A rate-limited response arrives as HTTP 400 (or 429) with the body code RATELIMITED, mapped to tracker_api_error and retried with exponential backoff.


Error model

A Linear response is an error when its body carries a non-empty top-level errors array, even on HTTP 200, or when the HTTP layer itself fails. The adapter parses the body errors array first and falls back to the HTTP status only when no errors array is present. Classification keys on extensions.type; extensions.code is diagnostic only, with one exception for the rate-limit signal.

There is no dedicated not-found type or code. An error whose message begins with entity not found (case-insensitive) maps to tracker_not_found. This check runs first, before any type-based rule, because a missing entity arrives under the generic invalid input type.

Body-level classification

SignalError kindRetryable
message begins with entity not foundtracker_not_foundNo
extensions.code == "RATELIMITED" or extensions.type == "ratelimited"tracker_api_errorYes
extensions.type == "authentication error"tracker_auth_errorNo
extensions.type == "forbidden" or "feature not accessible"tracker_auth_errorNo
extensions.type in "invalid input", "user error", "graphql error", or userError: truetracker_payload_errorNo
extensions.type in "internal error", "network error", "lock timeout", "bootstrap error"tracker_transport_errorYes
Any other errors entrytracker_api_errorDepends

HTTP-status fallback

Applied when a non-2xx response carries no errors array.

HTTP statusError kindRetryable
400tracker_payload_errorNo
401, 403tracker_auth_errorNo
429tracker_api_errorYes
5xxtracker_transport_errorYes
Othertracker_api_errorDepends

A transport failure (DNS, TCP, TLS, timeout, or body-read failure) maps to tracker_transport_error. The error message carries the first error’s userPresentableMessage, falling back to its message, so operators see Linear’s own wording.

For the full error taxonomy and operator guidance, see the error reference.


Adapter registration

The adapter registers itself under kind "linear" via an init function in internal/tracker/linear. Registration metadata declares:

PropertyValue
RequiresProjecttrue
RequiresAPIKeytrue
ValidateTrackerConfigOffline config diagnostics for sortie validate.

The orchestrator’s preflight validation uses RequiresProject and RequiresAPIKey to produce specific error messages before adapter construction. ValidateTrackerConfig runs the Linear-specific offline checks (team-key format, SORTIE_LINEAR_API_KEY hint, empty or whitespace state names, state overlap, and handoff_state collisions) without making network calls.


Key differences from the Jira and GitHub adapters

AspectJiraGitHubLinear
ProtocolREST, multiple endpointsREST, multiple endpointsGraphQL, single POST endpoint
Auth headerBasic base64(email:token)Bearer <token><api_key> verbatim, no scheme prefix
Error transportHTTP status codesHTTP status codeserrors[] inside HTTP 200 bodies
State modelWorkflow states + transition graphopen/closed + labels-as-statesTeam-scoped named states + 7 type categories
IdentifierPROJ-123 (project key)299 (repo-scoped number)ENG-123 (team key + number), plus UUID
PaginationnextPageToken / offsetLink headerRelay cursors (pageInfo, endCursor)
Rate-limit modelPoints quota (65K/hr)Requests (5K/hr) + search (30/min)Requests (dynamic) + complexity (3M/hr, 10K/query)

See the Jira adapter reference and the GitHub adapter reference.


Related pages

Was this page helpful?