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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
kind | string | Yes | - | Must be "linear". |
api_key | string | Yes | - | Linear personal API key. Sent verbatim in the Authorization header, no Bearer prefix. See authentication. |
project | string | Yes | - | Linear team key (e.g., ENG), the prefix on issue identifiers. Not a Linear project. See identifiers and team scoping. |
endpoint | string | No | https://api.linear.app/graphql | GraphQL 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_states | list of strings | No | ["Backlog", "Todo", "In Progress"] | Workflow-state names eligible for dispatch. |
terminal_states | list of strings | No | ["Done", "Canceled", "Duplicate"] | Workflow-state names that trigger workspace cleanup. |
handoff_state | string | No | (absent) | Workflow-state name set after a successful agent run. Must appear in neither active_states nor terminal_states. Absent disables handoff. |
query_filter | string | No | "" | 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
- Duplicateapi_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:
| Header | Value |
|---|---|
Authorization | The api_key value, verbatim. |
Content-Type | application/json |
User-Agent | sortie/<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.
| Value | Example | Properties |
|---|---|---|
issue.id | a7c4f8e2-1b9d-4e3a-8f2c-6d5e4a3b2c1f | UUID. Stable, globally unique. |
issue.identifier | ENG-123 | Human-readable. Team key plus issue number. |
issue.number | 123 | Numeric 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 field | Linear source | Normalization |
|---|---|---|
.issue.id | issue.id | UUID string, as-is. |
.issue.identifier | issue.identifier | String, as-is (e.g., ENG-123). |
.issue.title | issue.title | String, as-is. |
.issue.description | issue.description | Markdown. Null maps to empty string. |
.issue.priority | issue.priority | 0 (No priority) maps to nil. 1 (Urgent), 2 (High), 3 (Medium), 4 (Low) map to a non-nil integer. |
.issue.state | issue.state.name | String with original casing preserved. |
.issue.branch_name | issue.branchName | Opaque string, as-is. The prefix is workspace-configurable; it is never parsed. |
.issue.url | issue.url | String, as-is. Provided directly, not constructed. |
.issue.labels | issue.labels.nodes[].name | Each label lowercased. Non-nil empty list when no labels. |
.issue.assignee | assignee.displayName | Fallback 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.parent | issue.parent | {id, identifier} to {.id, .identifier}. nil when absent. |
.issue.comments | Separate connection | nil on candidate fetch. Populated when the issue is read individually. |
.issue.blocked_by | issue.inverseRelations.nodes | Nodes where type == "blocks". See blocker extraction. |
.issue.created_at | issue.createdAt | ISO-8601 timestamp string, as-is. |
.issue.updated_at | issue.updatedAt | ISO-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 field | Linear source | Normalization |
|---|---|---|
.id | comment.id | String, as-is. |
.author | comment.user.displayName | Fallback to user.name, then botActor.name, else empty string. |
.body | comment.body | Markdown pass-through. |
.created_at | comment.createdAt | ISO-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:
| Field | Source |
|---|---|
.id | node.issue.id |
.identifier | node.issue.identifier |
.state | node.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.
| Property | Value |
|---|---|
| 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 |
| Cursor | Opaque 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
| Signal | Error kind | Retryable |
|---|---|---|
message begins with entity not found | tracker_not_found | No |
extensions.code == "RATELIMITED" or extensions.type == "ratelimited" | tracker_api_error | Yes |
extensions.type == "authentication error" | tracker_auth_error | No |
extensions.type == "forbidden" or "feature not accessible" | tracker_auth_error | No |
extensions.type in "invalid input", "user error", "graphql error", or userError: true | tracker_payload_error | No |
extensions.type in "internal error", "network error", "lock timeout", "bootstrap error" | tracker_transport_error | Yes |
Any other errors entry | tracker_api_error | Depends |
HTTP-status fallback
Applied when a non-2xx response carries no errors array.
| HTTP status | Error kind | Retryable |
|---|---|---|
| 400 | tracker_payload_error | No |
| 401, 403 | tracker_auth_error | No |
| 429 | tracker_api_error | Yes |
| 5xx | tracker_transport_error | Yes |
| Other | tracker_api_error | Depends |
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
| Aspect | Jira | GitHub | Linear |
|---|---|---|---|
| Protocol | REST, multiple endpoints | REST, multiple endpoints | GraphQL, single POST endpoint |
| Auth header | Basic base64(email:token) | Bearer <token> | <api_key> verbatim, no scheme prefix |
| Error transport | HTTP status codes | HTTP status codes | errors[] inside HTTP 200 bodies |
| State model | Workflow states + transition graph | open/closed + labels-as-states | Team-scoped named states + a type category |
| Identifier | PROJ-123 (project key) | 299 (repo-scoped number) | ENG-123 (team key + number), plus UUID |
| Pagination | nextPageToken / offset | Link header | Relay cursors (pageInfo, endCursor) |
| Rate-limit model | Per-tenant points quota | Separate REST and search budgets | Per-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
- How to connect Sortie to Linear: setup instructions with authentication, state mapping, and verification
- WORKFLOW.md configuration reference: full schema for the
trackersection and all other configuration - Error reference: all tracker error kinds with retry behavior and operator actions
- Environment variables reference:
$VARexpansion modes and agent passthrough variables - Prometheus metrics reference:
sortie_tracker_requests_totaland related counters - How to write a prompt template: using
.issuefields (populated by this adapter) in templates - State machine reference: orchestration states, candidate eligibility, and how tracker state drives dispatch
- How to use the file adapter for local testing: test prompts and hooks without Linear API credentials
- Dashboard reference: live monitoring of issues fetched by this adapter
Was this page helpful?