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.
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
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.
More by WarrenZhu050413
View allReference Warren's Neovim configuration (~/.config/nvim) with LSP, plugins, and directory structure.
Prioritize brevity, directness, and clarity in all responses with minimal token usage.
Explore complex, multi-faceted topics requiring deep understanding through a three-round iterative strategy with parallel subagents.
Manage comprehensive Spanish learning system with A2 level support, vocabulary tracking, TTS integration, practice management, and deep search capabilities.
