| name | Using Linear |
| description | Guide for working with Linear project management via GraphQL API. Use when creating/updating Linear issues, changing status, adding comments, or uploading files/screenshots. Covers raw GraphQL, Python SDK (linear-py), issue search, mutations, and file upload workflows with Google Cloud Storage signed URLs. Tested October 2025. |
Using Linear
Comprehensive guide for working with Linear project management system via GraphQL API and Python SDK.
Last tested: October 26, 2025 Test workspace: cs1060f25 Recommended approach: Raw GraphQL with urllib
When to Use This Skill
Use this skill when:
- Creating or updating Linear issues programmatically
- Searching for issues by identifier or filter
- Changing issue status (e.g., marking as "Done")
- Adding comments to issues
- Uploading files or screenshots to Linear issues
- Automating Linear workflows with Python scripts
API Approaches
Raw GraphQL (Recommended) ⭐
Pros: Full feature support, file uploads, variables, no dependencies Cons: More verbose Use when: Need file uploads, complex queries, or full API access
import json
import urllib.request
def graphql_request(query, variables=None):
url = "https://api.linear.app/graphql"
payload = {"query": query}
if variables:
payload["variables"] = variables
req = urllib.request.Request(
url,
data=json.dumps(payload).encode('utf-8'),
headers={
"Authorization": "lin_api_...",
"Content-Type": "application/json"
}
)
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode('utf-8'))
Python SDK (linear-py)
Pros: Simpler API for basic operations
Cons: No file uploads, no variables in queries, limited field support (no estimate!)
Use when: Only need simple reads
from linear import Linear
client = Linear("lin_api_...")
teams = client.teams() # Returns list of dicts
SDK Limitations:
- Uses snake_case (
team_id) not camelCase (teamId) - Cannot pass variables to GraphQL queries
- No file upload support
estimateparameter doesn't work in create_issue
Recommendation: Use raw GraphQL for any real work. SDK only for quick prototypes.
Comprehensive Guide
See references/linear-api-comprehensive-guide.md for:
- Detailed SDK comparison with tested examples
- Advanced GraphQL patterns (pagination, filtering, bulk operations)
- File upload workflow variations
- Common gotchas and solutions
- Testing results from cs1060f25 workspace
API Authentication
Linear uses API tokens for authentication. Include the token in request headers:
headers = {
"Authorization": "lin_api_...",
"Content-Type": "application/json"
}
API Endpoint: https://api.linear.app/graphql
Common Operations
1. Search for Issue by Identifier
To find an issue like "UNIFIED-26":
query = """
query SearchIssues($filter: IssueFilter!) {
issues(filter: $filter) {
nodes {
id
identifier
title
}
}
}
"""
variables = {
"filter": {
"number": {"eq": 26} # Extract number from "UNIFIED-26"
}
}
result = graphql_request(query, variables)
# Find exact match
for issue in result["data"]["issues"]["nodes"]:
if issue["identifier"] == "UNIFIED-26":
return issue
Important: Filter by number first, then match exact identifier to handle multiple teams.
2. Update Issue Status
To mark an issue as "Done":
mutation = """
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
state {
name
}
}
}
}
"""
variables = {
"id": issue_id,
"input": {
"stateId": "done_state_id" # Get from team workflow states
}
}
3. Update Issue Due Date
To set a due date on an issue:
mutation = """
mutation UpdateIssueDueDate($id: String!, $dueDate: TimelessDate!) {
issueUpdate(id: $id, input: {dueDate: $dueDate}) {
success
issue {
id
identifier
dueDate
}
}
}
"""
variables = {
"id": issue_id,
"dueDate": "2025-11-03" # ISO 8601 format: YYYY-MM-DD
}
result = graphql_request(mutation, variables)
TimelessDate Type:
- Scalar type that accepts ISO 8601 date format:
YYYY-MM-DD - Also accepts shortcuts like
"2021"for midnight Jan 01 2021 - Accepts ISO 8601 duration strings added to current date (e.g.,
"-P2W1D"= 2 weeks and 1 day ago) - Common format:
"2025-11-03"for November 3, 2025
4. Update Issue Labels
IMPORTANT: Linear doesn't have issueAddLabel mutation. Use issueUpdate with labelIds array instead.
To add or update labels on an issue:
# First, get current labels
get_issue_query = """{
issues(filter: {identifier: {eq: "UNIFIED-15"}}) {
nodes {
id
labels { nodes { id name } }
}
}
}"""
issue_result = graphql_request(get_issue_query)
issue = issue_result['data']['issues']['nodes'][0]
issue_id = issue['id']
current_label_ids = [label['id'] for label in issue['labels']['nodes']]
# Add new label to existing ones
mutation = """
mutation UpdateIssueLabels($id: String!, $labelIds: [String!]!) {
issueUpdate(id: $id, input: {labelIds: $labelIds}) {
success
issue {
id
labels { nodes { name } }
}
}
}
"""
# Append new label ID to existing labels
new_label_ids = current_label_ids + [new_label_id]
variables = {
"id": issue_id,
"labelIds": new_label_ids # Array of ALL label IDs (existing + new)
}
result = graphql_request(mutation, variables)
Key points:
labelIdsparameter REPLACES all labels (doesn't append)- Always include existing label IDs + new ones
- Use
String!type for label IDs, notID!
4. Add Comment to Issue
To add a text comment (supports Markdown):
mutation = """
mutation CreateComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
body
}
}
}
"""
variables = {
"input": {
"issueId": issue_id,
"body": "Comment text with **Markdown** support"
}
}
File Upload Workflow
Uploading files to Linear requires a two-step process with Google Cloud Storage signed URLs.
Overview
- Request upload URL from Linear GraphQL API
- Upload file to Google Cloud Storage with required headers
- Use returned asset URL in comments or issue descriptions
Critical Discovery: Required Headers
Google Cloud Storage signed URLs require EXACT headers that match the cryptographic signature. Linear's API provides these headers - they MUST be included in the upload request.
Step 1: Request Upload URL
Query Linear's fileUpload mutation with file metadata:
query = """
mutation FileUpload($size: Int!, $filename: String!, $contentType: String!) {
fileUpload(size: $size, filename: $filename, contentType: $contentType) {
success
uploadFile {
uploadUrl
assetUrl
headers {
key
value
}
}
}
}
"""
variables = {
"size": os.path.getsize(file_path),
"filename": os.path.basename(file_path),
"contentType": "image/png" # or appropriate MIME type
}
result = graphql_request(query, variables)
upload_data = result["data"]["fileUpload"]["uploadFile"]
Response includes:
uploadUrl: Google Cloud Storage signed URL (valid for 60 seconds)assetUrl: Final Linear CDN URL for the uploaded fileheaders: Array of required headers for upload
Step 2: Upload File with Required Headers
Critical: Include ALL headers returned by Linear API:
curl -X PUT \
-H "Content-Type: image/png" \
-H "x-goog-content-length-range: [exact_size],[exact_size]" \
-H 'Content-Disposition: attachment; filename="..."' \
-T /path/to/file \
"[uploadUrl]"
Headers breakdown:
Content-Type: Must matchcontentTypefrom mutation (part of signature)x-goog-content-length-range: Exact file size range (provided by Linear)Content-Disposition: Filename for download (provided by Linear)
Python example using urllib:
import urllib.request
# Build headers from Linear's response
upload_headers = {"Content-Type": "image/png"}
for header in upload_data["headers"]:
upload_headers[header["key"]] = header["value"]
# Read file
with open(file_path, 'rb') as f:
file_data = f.read()
# Upload with PUT request
req = urllib.request.Request(
upload_data["uploadUrl"],
data=file_data,
headers=upload_headers,
method='PUT'
)
with urllib.request.urlopen(req) as response:
if response.status in [200, 201]:
print(f"✅ Upload successful")
asset_url = upload_data["assetUrl"]
Step 3: Use Asset URL in Comments
Embed the uploaded image in a comment using Markdown:
comment_body = f"""✅ Screenshot uploaded
"""
# Add comment with embedded image
mutation = """
mutation CreateComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
}
}
"""
variables = {
"input": {
"issueId": issue_id,
"body": comment_body
}
}
Common Errors and Solutions
HTTP 400: Bad Request (GraphQL Mutation)
Cause: Query syntax error, wrong mutation name, or incorrect variable types
Solution:
- Search for correct syntax - Linear's GraphQL schema may not match documentation
# Use searching-deeply skill or Exa mcp__exa__get_code_context_exa({ query: "Linear API GraphQL mutation [operation] syntax", tokensNum: 3000 }) - Check error message in response for hints
- Verify mutation exists in Linear schema (common mistake:
issueAddLabeldoesn't exist) - Confirm variable types match schema (
String!vsID!) - Look for production code examples showing correct usage
Example: Adding labels returns 400 because issueAddLabel doesn't exist → Use issueUpdate with labelIds instead
HTTP 400: Bad Request (File Upload)
Cause: Missing required headers or header mismatch
Solution:
- Check that
headersfield is included infileUploadmutation query - Add ALL headers from Linear's response to upload request
- Ensure
Content-Typematches exactly
HTTP 403: Signature Does Not Match
Cause: Headers don't match signed URL signature
Solution:
- Ensure headers are added in exact order
- Don't modify header values
- Upload file within 60-second window (signed URLs expire)
Upload Succeeds but Returns Empty Response
Expected behavior: Google Cloud Storage returns empty 200 response on success. Use the assetUrl from Step 1, not the upload response.
Python Template
Complete working example in scripts/upload_to_linear.py demonstrates:
- GraphQL request wrapper
- Issue search by identifier
- File upload with proper headers
- Comment creation with embedded image
Use this template as a reference for Linear automation workflows.
Tips and Best Practices
Signed URL expiration: Upload URLs expire in 60 seconds. Get fresh URL for each upload.
File size: Include exact file size in mutation. Linear uses this for
x-goog-content-length-rangeheader.Error handling: Linear API returns detailed error messages in GraphQL response
errorsfield.Rate limiting: Linear has API rate limits. Add delays between bulk operations.
Testing: Use Linear's GraphQL explorer at https://linear.app/your-workspace/settings/api for query testing.
Asset URLs: Linear asset URLs are permanent CDN links. Safe to store in documentation or external systems.
Reference Script
See scripts/upload_to_linear.py for complete implementation with error handling and retry logic.