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 same issue object fields. 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. A present value must be an absolute http(s) URL with a hostname, or the adapter is rejected when it is built.
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. Must appear in neither active_states nor terminal_states. Absent disables handoff.
query_filterstringNo""Raw Linear IssueFilter JSON fragment, ANDed with the team and state constraints. See query filter.

user_agent is not a Linear adapter config key an operator can set. Sortie sets the tracker role’s value to its own version string, and linear fills no SCM or CI role, so a value supplied in a top-level linear: block is ignored.

tracker.in_progress_state is validated and executed by the orchestrator the same way for every tracker kind: it drives a dispatch-time transition by transitioning the issue, gated on dispatch posture rather than on tracker kind, so it works under kind: linear the same way it does under Jira or GitHub. The one real difference is when each check runs. The Linear adapter reads active_states, terminal_states, and handoff_state when it is built and checks each against the team’s workflow states (see canonical-casing preflight), but it never reads in_progress_state itself. A misconfigured in_progress_state therefore surfaces only at dispatch time, as a tracker_payload_error from the transition, rather than as a failure when the adapter is built.

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.

sortie validate checks endpoint, project, and the state lists for this same shape without making network calls, reusing the rules enforced when the adapter is built. A malformed endpoint is reported as tracker.endpoint.invalid; an empty value is not, since the adapter substitutes the default host for it. Unlike the sibling forge adapters, there is no plain-http warning here, because Linear has no self-hosted deployment mode to make the distinction meaningful. A project value containing whitespace is an error, and one containing a slash draws a warning that it looks like a GitHub-style owner/repo value rather than a Linear team key. An empty or padded state name is an error here, not a warning as on the sibling forge adapters, because the adapter matches a configured name against the team’s workflow states exactly; an overlap between active_states and terminal_states is also an error. State collisions involving handoff_state or in_progress_state are rejected before Sortie starts, the same way for every tracker kind; see startup and configuration errors.

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 adapter sends the key exactly as configured, so the value must be the bare key with no scheme and no surrounding whitespace; a Bearer prefix or stray whitespace becomes part of the credential and fails authentication. Personal keys carry the lin_api_ prefix; sortie validate warns when a configured key lacks it or carries surrounding whitespace, and suggests api_key: $SORTIE_LINEAR_API_KEY when the field is empty but that variable is set.

Fixed headers on every request:

HeaderValue
AuthorizationThe api_key value, verbatim.
Content-Typeapplication/json
User-Agentsortie/<version>, set by Sortie.

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

Startup preflight

The adapter 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 fails the viewer query, which the adapter routes through the same error model that classifies every other call, mapping it to tracker_auth_error and blocking Sortie from starting.

OAuth

OAuth 2.0 is not supported. The orchestrator runs as a single headless principal with no interactive authorization flow and no user-facing callback, which an OAuth token exchange requires and a personal API key does not.


State model

Every Linear workflow state carries a type category defined by Linear; see external references for the full enumeration. States are team-scoped: two teams can each have an “In Progress” state with different UUIDs, and a team can have several states of the same type (for example, “In Review” and “QA”).

Name-based mapping

The adapter maps issues by configured state name, not by type. .issue.state is issue.state.name with original casing preserved. The type category does not drive selection; it serves a startup tripwire that treats three categories, completed, canceled, and duplicate, as terminal. The tripwire emits a WARN when a configured active_states entry resolves to one of those three categories, or a terminal_states entry resolves to a category outside them.

Canonical-casing preflight

Linear’s state.name.in filter is case-sensitive. When the adapter is built, it 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 is rejected when the adapter is built, with tracker_payload_error (state "<name>" not found in team "<key>"); an unknown team key is rejected the same way (unknown team key "<key>").

Default mapping

The adapter’s built-in defaults, applied when active_states or terminal_states is absent or empty:

active_states: [Backlog, Todo, In Progress]
terminal_states: [Done, Canceled, Duplicate]

handoff_state has no default; it stays absent, and dispatch-time handoff is disabled, unless configured.


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.

.issue.id maps to issue.id; .issue.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 issue object fields.

Template fieldLinear sourceNormalization
.issue.idissue.idUUID string, as-is.
.issue.identifierissue.identifierString, as-is (e.g., ENG-123).
.issue.titleissue.titleString, as-is.
.issue.descriptionissue.descriptionMarkdown. Null maps to empty string.
.issue.priorityissue.priority0 (No priority) maps to nil. 1 (Urgent), 2 (High), 3 (Medium), 4 (Low) map to a non-nil integer.
.issue.stateissue.state.nameString with original casing preserved.
.issue.branch_nameissue.branchNameOpaque string, as-is. The prefix is workspace-configurable; it is never parsed.
.issue.urlissue.urlString, as-is. Provided directly, not constructed.
.issue.labelsissue.labels.nodes[].nameEach label lowercased. Non-nil empty list when no labels.
.issue.assigneeassignee.displayNameFallback to name, then email. Null assignee maps to empty string.
.issue.issue_type(not available)Always empty. Linear has no native issue-type field.
.issue.parentissue.parent{id, identifier} to {.id, .identifier}. nil when absent.
.issue.commentsSeparate connectionnil on candidate fetch. Populated when the issue is read individually.
.issue.blocked_byissue.inverseRelations.nodesNodes where type == "blocks". See blocker extraction.
.issue.created_atissue.createdAtISO-8601 timestamp string, as-is.
.issue.updated_atissue.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) and is held out of dispatch: its blockers are marked unresolved, so .issue.blocked_by renders as nil rather than a possibly incomplete list. See candidate eligibility for the dispatch-side effect.

Comment normalization

Template fieldLinear sourceNormalization
.idcomment.idString, as-is.
.authorcomment.user.displayNameFallback to user.name, then botActor.name, else empty string.
.bodycomment.bodyMarkdown pass-through.
.created_atcomment.createdAtISO-8601 timestamp string, as-is.

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

Blocker extraction

.issue.blocked_by 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 blocker entry is produced:

FieldSource
.idnode.issue.id
.identifiernode.issue.identifier
.statenode.issue.state.name

This data arrives on the same response as a candidate fetch, so resolving blockers costs no separate request, unlike the GitHub and Gitea adapters.


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 when the adapter is built, 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 candidate polling and state-based lookups. It does not apply to looking up issue states by ID or by identifier, which use id and number connection filters; those issues already passed filtering at dispatch time.


Labels

Linear attaches labels by id, not by name, so adding a label by name is a resolve-then-attach sequence. The adapter looks up the name case-insensitively and prefers a label scoped to the configured team over a workspace-scoped label of the same name.

When no label matches, the adapter creates one, always scoped to the configured team. If that create fails with a payload-class error, the adapter re-resolves once on the assumption a concurrent request already created the label, and returns the original create error only if that second resolution also finds nothing. A create refused for the team maps to tracker_auth_error.

The label is attached through Linear’s append-only field, so the issue’s existing labels are never read or replaced. A label failure is not fatal to the run.

Label creation is also gated by a team-level permission setting that some workspaces restrict to team owners; a credential that can otherwise read and write can still be refused there. See Linear’s own documentation for what that setting is currently called and how to change it.


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
Page cap (top-level connections)200 pages; the walk logs a WARN and returns the items accumulated so far rather than continuing past it
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 meters both a request budget and a query-complexity budget, and scales the request budget with the size of the workspace. The current quotas are Linear’s to publish, and the adapter reads the remaining allowance from the response headers rather than assuming a figure.

Sortie does not throttle client-side. When the remaining allowance reaches zero the adapter logs a rate limit exhausted warning. A throttled response classifies as tracker_api_error; the orchestrator does not retry it with backoff, it logs the failure and waits for the next poll interval. Poll cadence is the control: raise polling.interval_ms or narrow query_filter.

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.


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 + a type category
IdentifierPROJ-123 (project key)299 (repo-scoped number)ENG-123 (team key + number), plus UUID
PaginationnextPageToken / offsetLink headerRelay cursors (pageInfo, endCursor)
Rate-limit modelPer-tenant points quotaSeparate REST and search budgetsPer-workspace request budget plus a query-complexity budget

See the Jira adapter reference and the GitHub adapter reference.


External references

  • Linear GraphQL API: schema, authentication, and the personal API key this adapter uses
  • Pagination: cursor conventions behind the adapter’s page walking
  • Filtering: filter syntax valid in tracker.query_filter
  • Rate limiting: current request and complexity budgets

Related pages

Was this page helpful?