How to Build an AI Agent for Linear

A complete guide to building your first Linear agent - from OAuth setup to production deployment

Linear recently launched their Agent API, allowing you to build AI-powered assistants that can be assigned to issues, analyze content, and take actions automatically. In this tutorial, we'll build Orla - an AI agent that automatically labels issues created from marker.io.

By the end of this guide, you'll have a working Linear agent that:

  • 🤖 Appears in your assignee dropdown

  • 📝 Receives webhooks when assigned to issues

  • 🧠 Uses GPT-4 to analyze issue content

  • 🏷️ Automatically applies appropriate labels

  • 💬 Sends progress updates and notifications

Time to complete: ~2-3 hours
Prerequisites: Node.js, TypeScript basics, Linear workspace with admin access

Table of Contents

  1. Understanding Linear Agents

  2. Project Setup

  3. OAuth Authentication

  4. Building the Agent

  5. Deploying to Railway

  6. Testing Your Agent

  7. Troubleshooting

Understanding Linear Agents

What is a Linear Agent?

A Linear Agent is a special type of OAuth application that can:

  • Be assigned to issues (like a team member)

  • Receive real-time notifications via webhooks

  • Send "Agent Activities" (progress updates, messages, results)

  • Act autonomously on behalf of the application

Think of it as a bot user with superpowers - it can see issues assigned to it, perform actions, and communicate back through Linear's UI.

How Agents Work

1. User assigns issue to agent
2. Linear sends AgentSessionEvent webhook to your server
3. Your agent processes the request (analyze, take actions, etc.)
4. Agent sends Activities back to Linear (progress updates)
5. User sees agent working in real-time

The magic is in Agent Activities - structured messages that appear in Linear's UI showing what your agent is thinking, doing, and results.

Key Concepts

Agent Session: A conversation between Linear and your agent about a specific issue.

Agent Activities: Five types of messages you can send:

  • thought - Internal reasoning (e.g., "Analyzing issue...")

  • action - Tool invocations (e.g., "Searching database...")

  • elicitation - Questions for the user (e.g., "Which category?")

  • response - Final results (e.g., "I've labeled this issue")

  • error - Error messages (e.g., "Failed to access API")

Ephemeral Activities: Temporary messages that get replaced by the next activity - perfect for progress updates!

Project Setup

Let's create our agent. We'll use TypeScript, Express for webhooks, the Linear SDK, and OpenAI for intelligent analysis.

Initialize the Project

mkdir orla && cd orla
npm init -y
npm install @linear/sdk express dotenv openai axios
npm install -D typescript @types/node @types/express tsx

TypeScript Configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Package Scripts

Update package.json:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Environment Variables

Create .env:

LINEAR_API_KEY=        # We'll fill this in later
OPENAI_API_KEY=        # Your OpenAI API key
PORT=3000

Project Structure

orla/
├── src/
│   ├── index.ts              # Express server + webhook receiver
│   ├── agent-handler.ts      # Main agent logic
│   ├── label-analyzer.ts     # AI-powered analysis
│   ├── linear-client.ts      # Linear API wrapper
│   ├── oauth-handler.ts      # OAuth token exchange
│   ├── config.ts             # Configuration
│   └── types.ts              # TypeScript types
├── package.json
├── tsconfig.json
└── .env

OAuth Authentication

This is the most critical part. Linear agents require proper OAuth authentication - personal API keys won't work!

Step 1: Create OAuth Application

  1. Go to Linear → Settings → API → Applications

  2. Click "Create new OAuth application"

  3. Fill in the details:

Name: Orla
Description: AI Project Manager
Developer: Your Name/Company
Callback URL: https://your-domain.com/auth/callback  (we'll update this later)
Webhook URL: https://your-domain.com/webhooks/linear
  1. Set Scopes (check these boxes):

    • read - Read workspace data

    • write - Write to workspace

    • issues:create - Create/update issues

    • comments:create - Create comments

    • app:assignable - CRITICAL! Makes app assignable

  2. Enable Webhooks:

    • ✅ Agent session events

  3. Copy your Client ID and Client Secret

Step 2: Understanding Actor Authorization

Here's the crucial part: Linear has different types of tokens, and only one works for agents.

Personal API Key

// Acts as YOU, the user
// Cannot be assigned to issues
// Cannot send Agent Activities

OAuth Token (standard flow)

// Acts on behalf of user
// Still user-level permissions
// Cannot be assigned as agent

OAuth Token (actor=app flow)

// Acts as THE APPLICATION
// Can be assigned to issues
// Can send Agent Activities
// This is what we need!

Step 3: Build OAuth Handler

Create src/oauth-handler.ts:

import axios from 'axios';

const OAUTH_CLIENT_ID = 'your-client-id';
const OAUTH_CLIENT_SECRET = 'your-client-secret';
const OAUTH_REDIRECT_URI = 'https://your-domain.com/auth/callback';

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope: string;
}

export class OAuthHandler {
  /**
   * Exchange authorization code for access token
   */
  async exchangeCode(code: string): Promise<string> {
    const response = await axios.post<TokenResponse>(
      'https://api.linear.app/oauth/token',
      {
        grant_type: 'authorization_code',
        code,
        client_id: OAUTH_CLIENT_ID,
        client_secret: OAUTH_CLIENT_SECRET,
        redirect_uri: OAUTH_REDIRECT_URI,
      },
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      }
    );

    return response.data.access_token;
  }
}

Step 4: The Authorization URL

The actor=app parameter is critical:

https://linear.app/oauth/authorize?
  client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-domain.com/auth/callback
  &scope=read,write,issues:create,comments:create,app:assignable
  &actor=app              ← THIS MAKES IT WORK!
  &response_type=code

Without actor=app, Linear treats it as a user-level OAuth flow, and your agent won't be assignable.

Step 5: Callback Endpoint

Create src/index.ts:

import express from 'express';
import { OAuthHandler } from './oauth-handler';

const app = express();
const oauthHandler = new OAuthHandler();

app.use(express.json());

// OAuth callback endpoint
app.get('/auth/callback', async (req, res) => {
  const { code } = req.query;

  if (!code || typeof code !== 'string') {
    return res.status(400).send('Missing authorization code');
  }

  try {
    const accessToken = await oauthHandler.exchangeCode(code);
    
    res.send(`
      <h1>✅ Authorization Successful!</h1>
      <p>Your OAuth token:</p>
      <pre>${accessToken}</pre>
      <p><strong>Save this token as LINEAR_API_KEY in your .env file!</strong></p>
    `);
  } catch (error) {
    console.error('OAuth error:', error);
    res.status(500).send('Error exchanging authorization code');
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Building the Agent

Step 1: Type Definitions

Create src/types.ts:

export interface AgentSessionWebhook {
  type: 'AgentSessionEvent';
  action: 'created' | 'prompted';
  agentSession: {
    id: string;
    issue?: {
      id: string;
      title: string;
      description?: string;
      identifier: string;
      url: string;
      teamId: string;
    };
  };
  previousComments?: Array<{
    body: string;
  }> | null;
  guidance?: string | null;
}

export const CATEGORY_LABELS = [
  'Bug',
  'Design',
  'Dev',
  'Marketing',
  'Needs Spec',
  'Operations'
] as const;

export const PARTNER_LABELS = [
  'ArborXR',
  'Chameleon',
  'Inserve',
  'Keyplay',
  // ... add your labels
] as const;

Step 2: Linear Client Wrapper

Create src/linear-client.ts:

import { LinearClient } from '@linear/sdk';
import { config } from './config';

export class OrlaLinearClient {
  private client: LinearClient;

  constructor() {
    this.client = new LinearClient({
      apiKey: config.linearApiKey,
    });
  }

  /**
   * Send an agent activity to Linear
   */
  async sendActivity(
    agentSessionId: string,
    content: {
      type: 'thought' | 'action' | 'elicitation' | 'response' | 'error';
      body?: string;
      action?: string;
      parameter?: string;
      result?: string;
    },
    ephemeral: boolean = false
  ) {
    // Use GraphQL directly (SDK doesn't have this method yet)
    const result = await this.client.client.rawRequest(`
      mutation AgentActivityCreate($input: AgentActivityCreateInput!) {
        agentActivityCreate(input: $input) {
          success
          agentActivity {
            id
          }
        }
      }
    `, {
      input: {
        agentSessionId,
        content,
        ephemeral,
      }
    });

    const data = result.data as any;
    if (!data?.agentActivityCreate?.success) {
      throw new Error('Failed to create agent activity');
    }

    return data.agentActivityCreate.agentActivity;
  }

  /**
   * Get issue details
   */
  async getIssue(issueId: string) {
    return await this.client.issue(issueId);
  }

  /**
   * Add labels to an issue
   */
  async addLabelsToIssue(issueId: string, labelIds: string[]) {
    const issue = await this.getIssue(issueId);
    
    // Get existing labels
    const existingLabels = await issue.labels();
    const existingLabelIds = existingLabels.nodes.map(label => label.id);
    
    // Merge with new labels (avoid duplicates)
    const allLabelIds = [...new Set([...existingLabelIds, ...labelIds])];

    await this.client.updateIssue(issueId, {
      labelIds: allLabelIds,
    });
  }

  /**
   * Create a comment mentioning a user
   */
  async createComment(issueId: string, body: string, userId?: string) {
    let commentBody = body;
    
    if (userId) {
      const user = await this.client.user(userId);
      const userUrl = user.url;
      // Use Linear URL format for mentions
      commentBody = `[${user.displayName}](${userUrl}), ${body}`;
    }

    await this.client.createComment({
      issueId,
      body: commentBody,
    });
  }

  /**
   * Get all available labels
   */
  async getAllLabels() {
    const labels = await this.client.issueLabels();
    return labels.nodes;
  }
}

Step 3: AI-Powered Label Analyzer

Create src/label-analyzer.ts:

import OpenAI from 'openai';
import { config } from './config';
import { CATEGORY_LABELS, PARTNER_LABELS } from './types';

const openai = new OpenAI({
  apiKey: config.openaiApiKey,
});

export class LabelAnalyzer {
  async analyzeIssue(title: string, description: string) {
    const prompt = `Analyze this Linear issue and determine appropriate labels:

Title: ${title}

Description:
${description}

Available Category Labels: ${CATEGORY_LABELS.join(', ')}
Available Partner Labels: ${PARTNER_LABELS.join(', ')}

Extract partner information from URLs (e.g., staging.inserve.nl → Inserve), 
email addresses, or company names in the description.

Return JSON with this structure:
{
  "categoryLabels": ["label1", "label2"],
  "partnerLabel": "PartnerName" or null,
  "needsPartnerClarification": true/false,
  "reasoning": "brief explanation"
}`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [
        {
          role: 'system',
          content: 'You are an AI that assigns labels to Linear issues. Return valid JSON only.'
        },
        {
          role: 'user',
          content: prompt
        }
      ],
      response_format: { type: 'json_object' },
      temperature: 0.3,
    });

    const content = completion.choices[0]?.message?.content;
    if (!content) {
      throw new Error('No response from OpenAI');
    }

    return JSON.parse(content);
  }
}

Step 4: Agent Handler

Create src/agent-handler.ts:

import { OrlaLinearClient } from './linear-client';
import { LabelAnalyzer } from './label-analyzer';
import { AgentSessionWebhook } from './types';

export class AgentHandler {
  private linearClient: OrlaLinearClient;
  private labelAnalyzer: LabelAnalyzer;

  constructor() {
    this.linearClient = new OrlaLinearClient();
    this.labelAnalyzer = new LabelAnalyzer();
  }

  async handleSessionCreated(webhook: AgentSessionWebhook) {
    const { agentSession } = webhook;
    const issue = agentSession.issue;

    if (!issue) {
      console.log('No issue in agent session');
      return;
    }

    try {
      // 1. Send initial thinking activity (ephemeral)
      await this.linearClient.sendActivity(
        agentSession.id,
        {
          type: 'thought',
          body: `Analyzing issue **${issue.identifier}**...`,
        },
        true // ephemeral - will be replaced
      );

      // 2. Check if from marker.io
      const isFromMarker = issue.description?.includes('marker.io');
      
      if (!isFromMarker) {
        await this.linearClient.sendActivity(
          agentSession.id,
          {
            type: 'response',
            body: `This issue doesn't appear to be from marker.io. Let me know if you'd like me to analyze it anyway!`,
          }
        );
        return;
      }

      // 3. Analyze with AI (ephemeral progress update)
      await this.linearClient.sendActivity(
        agentSession.id,
        {
          type: 'action',
          action: 'Analyzing',
          parameter: 'Issue content for appropriate labels',
        },
        true // ephemeral
      );

      const analysis = await this.labelAnalyzer.analyzeIssue(
        issue.title,
        issue.description || ''
      );

      // 4. Get label IDs
      const allLabels = await this.linearClient.getAllLabels();
      const labelIds: string[] = [];

      for (const labelName of analysis.categoryLabels) {
        const label = allLabels.find(l => l.name === labelName);
        if (label) labelIds.push(label.id);
      }

      if (analysis.partnerLabel) {
        const label = allLabels.find(l => l.name === analysis.partnerLabel);
        if (label) labelIds.push(label.id);
      }

      // 5. Apply labels (ephemeral progress update)
      if (labelIds.length > 0) {
        await this.linearClient.sendActivity(
          agentSession.id,
          {
            type: 'action',
            action: 'Adding',
            parameter: `${labelIds.length} label(s) to issue`,
          },
          true // ephemeral
        );

        await this.linearClient.addLabelsToIssue(issue.id, labelIds);
      }

      // 6. Build final response message
      let responseMessage = `I've analyzed and labeled **[${issue.identifier}](${issue.url})** from marker.io:\n\n`;
      
      if (analysis.categoryLabels.length > 0) {
        responseMessage += `**Category labels:** ${analysis.categoryLabels.join(', ')}\n`;
      }
      
      if (analysis.partnerLabel) {
        responseMessage += `**Partner:** ${analysis.partnerLabel}\n`;
      }
      
      responseMessage += `\n**Reasoning:** ${analysis.reasoning}`;

      // 7. Send final response (permanent)
      await this.linearClient.sendActivity(
        agentSession.id,
        {
          type: 'response',
          body: responseMessage,
        },
        false // permanent
      );

      // 8. Notify user via comment
      await this.linearClient.createComment(
        issue.id,
        `I've automatically labeled this marker.io issue. ${responseMessage.replace('I've analyzed and labeled', 'Labels applied:')}`,
        'your-user-id' // Replace with actual user ID
      );

    } catch (error) {
      console.error('Error handling session:', error);
      
      // Send error activity
      await this.linearClient.sendActivity(
        agentSession.id,
        {
          type: 'error',
          body: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}`,
        }
      );
    }
  }
}

Step 5: Webhook Receiver

Update src/index.ts with webhook handler:

import express, { Request, Response } from 'express';
import { AgentHandler } from './agent-handler';
import { AgentSessionWebhook } from './types';
import { OAuthHandler } from './oauth-handler';

const app = express();
const agentHandler = new AgentHandler();
const oauthHandler = new OAuthHandler();

app.use(express.json());

// Health check
app.get('/health', (req: Request, res: Response) => {
  res.json({ status: 'healthy', agent: 'orla' });
});

// OAuth callback (from earlier)
app.get('/auth/callback', async (req: Request, res: Response) => {
  // ... (OAuth code from earlier)
});

// Webhook receiver - THIS IS CRITICAL!
app.post('/webhooks/linear', async (req: Request, res: Response) => {
  try {
    // ✅ RESPOND IMMEDIATELY - Linear requires response within 5 seconds!
    res.status(200).json({ received: true });

    const webhook = req.body as AgentSessionWebhook;
    
    console.log(`📨 Received webhook: ${webhook.action} for session ${webhook.agentSession?.id}`);

    // ✅ Process asynchronously AFTER responding
    setImmediate(async () => {
      try {
        if (webhook.action === 'created') {
          await agentHandler.handleSessionCreated(webhook);
        } else if (webhook.action === 'prompted') {
          // Handle follow-up messages from user
        }
      } catch (error) {
        console.error('Error processing webhook:', error);
      }
    });

  } catch (error) {
    console.error('Error handling webhook:', error);
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`
╔═══════════════════════════════════════╗
║   🤖  Orla - AI Project Manager      ║
║   Status: Running                     ║
║   Port: ${PORT}                        ║
╚═══════════════════════════════════════╝

Webhook URL: http://localhost:${PORT}/webhooks/linear
Ready to receive webhooks! 🚀
  `);
});

Configuration File

Create src/config.ts:

import dotenv from 'dotenv';

dotenv.config();

export const config = {
  linearApiKey: process.env.LINEAR_API_KEY || '',
  openaiApiKey: process.env.OPENAI_API_KEY || '',
  port: parseInt(process.env.PORT || '3000', 10),
};

export function validateConfig() {
  const missing: string[] = [];
  
  if (!config.linearApiKey) missing.push('LINEAR_API_KEY');
  if (!config.openaiApiKey) missing.push('OPENAI_API_KEY');
  
  if (missing.length > 0) {
    throw new Error(`Missing: ${missing.join(', ')}`);
  }
}

Deploying to Railway

Railway is perfect for webhook-based apps - it's always-on (no cold starts), easy to deploy, and has a generous free tier.

Why Railway?

  • ✅ Always-on (critical for webhooks!)

  • ✅ Automatic HTTPS

  • ✅ Built-in logging

  • ✅ Simple deployment

  • ✅ $5 free credit/month

Step 1: Install Railway CLI

npm install -g @railway/cli
railway login

Step 2: Create Dockerfile

Create Dockerfile:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["npm", "start"]

Step 3: Initialize Railway Project

railway init
# Select "Empty Project"
# Name it "orla"

Step 4: Set Environment Variables

railway variables --set "LINEAR_API_KEY=lin_oauth_your_token_here"
railway variables --set "OPENAI_API_KEY=sk-your_key_here"

Step 5: Deploy

git init
git add .
git commit -m "Initial commit"
git push  # Or use: railway up

Step 6: Get Public URL

railway domain
# If no domain: railway domain create

You'll get a URL like: https://orla-production.up.railway.app

Step 7: Update Linear OAuth App

Go back to Linear OAuth app settings and update:

  • Callback URL: https://orla-production.up.railway.app/auth/callback

  • Webhook URL: https://orla-production.up.railway.app/webhooks/linear

Testing Your Agent

Step 1: Authorize the Agent

Visit this URL (replace with your client ID and domain):

https://linear.app/oauth/authorize?
  client_id=YOUR_CLIENT_ID
  &redirect_uri=https://orla-production.up.railway.app/auth/callback
  &scope=read,write,issues:create,comments:create,app:assignable
  &actor=app
  &response_type=code

Click "Authorize" → You'll be redirected and see your OAuth token → Copy it → Update Railway:

railway variables --set "LINEAR_API_KEY=the_new_token"

Step 2: Configure Team Access

In Linear OAuth app settings:

  • Go to Team access

  • Change to "All public teams and select private teams..."

  • Or select specific teams

  • Click Update

Step 3: Verify Agent Appears

In Linear:

  1. Open any issue

  2. Click Assignee dropdown

  3. Look for Orla under "Agents"

If you don't see it, try toggling the team access settings again.

Step 4: Test with Real Issue

Create a test issue:

Title: Test - Add social media image tags

Description:
Can you add og:image tags to the site?

Reported by: test@inserve.nl
Source URL: https://staging.inserve.nl/changelog
Marker.io Issue type: Improvement

Assign it to Orla and watch the magic happen! 🎉

You should see:

  1. Orla sends "Analyzing issue..." (ephemeral)

  2. "Analyzing: Issue content..." (ephemeral)

  3. "Adding: 2 label(s)..." (ephemeral)

  4. Final response with reasoning (permanent)

  5. Labels applied to the issue

  6. Comment notification

Step 5: Check Railway Logs

railway logs

You should see:

📨 Received webhook: created for session fb7e22db...
✅ Analyzing issue...
✅ Labels applied

Troubleshooting

Agent Not Showing in Assignees

Problem: Completed OAuth flow, but Orla doesn't appear in dropdown

Solutions:

  1. Verify you're using OAuth token, not personal API key

    curl -X POST https://api.linear.app/graphql \
      -H "Authorization: YOUR_TOKEN" \
      -d '{"query":"{ viewer { name email } }"}'
    
    # Should return email ending in: @oauthapp.linear.app
    
  2. Check authorization URL had actor=app

    • Re-authorize with the correct URL

  3. Verify team access settings

    • Linear → Applications → Orla → Team Access

    • Try toggling between settings and saving

  4. Check scopes include app:assignable

    • Linear → Applications → Orla → Permissions

Webhooks Not Received

Problem: Assign to Orla, nothing happens

Solutions:

  1. Test endpoint manually:

    curl -X POST https://your-domain.com/webhooks/linear \
      -H "Content-Type: application/json" \
      -d '{"test": "ping"}'
    
    # Should return: {"received": true}
    
  2. Check Railway logs:

    railway logs | grep "Webhook received"
    
  3. Verify webhook URL in Linear:

    • Must be: https://your-domain.com/webhooks/linear

    • Must be publicly accessible

  4. Check "Agent session events" is enabled:

    • Linear → Applications → Orla → Webhooks

Agent Activities Not Showing

Problem: Webhook received, but no activities in Linear

Solutions:

  1. Verify using OAuth token (not personal API key)

  2. Check agentSessionId is valid UUID:

    console.log('Session ID:', agentSession.id);
    // Should be UUID format: fb7e22db-b8af-4f17-94ff-177807ecf8d8

  3. Verify activity content structure:

    // Must match this exact structure
    {
      type: 'thought' | 'action' | 'elicitation' | 'response' | 'error',
      body?: string,      // For thought, response, error
      action?: string,    // For action
      parameter?: string, // For action
    }

Railway Build Failures

Problem: Deploy fails with "cannot find module" errors

Solutions:

  1. Ensure package-lock.json is committed:

    npm install  # Generates package-lock.json
    git add package-lock.json
    git commit -m "Add lockfile"
    
  2. Test build locally:

    npm run build
    # Fix any TypeScript errors before deploying
    

Next Steps

Congratulations! You've built a working Linear agent. Here are some ideas to extend it:

Enhanced Features

Priority Detection: Analyze urgency from issue content and set priority

if (description.includes('urgent') || description.includes('critical')) {
  await linearClient.updateIssue(issueId, { priority: 1 });
}

Time Estimation: Use AI to estimate completion time

const estimate = await openai.chat.completions.create({
  messages: [{
    role: 'user',
    content: `Estimate hours to complete: ${description}`
  }]
});

Auto-Assignment: Assign to team members based on labels or expertise

Learning from Feedback: Track when users change labels and improve accuracy

Multi-Step Workflows: Chain actions (label → assign → create subtasks)

Slack Integration: Notify Slack when high-priority issues are created

Production Improvements

Error Handling: Implement retries, better error messages Logging: Add structured logging (Winston, Pino) Monitoring: Set up Sentry for error tracking Rate Limiting: Handle Linear API rate limits gracefully Testing: Add unit tests and integration tests Caching: Cache label lookups to reduce API calls

Key Takeaways

Building a Linear agent teaches you:

  1. OAuth flows - Understanding actor authorization and token types

  2. Webhook design - Responding quickly, processing async

  3. Real-time communication - Using Agent Activities effectively

  4. AI integration - Combining GPT-4 with structured APIs

  5. Production deployment - Railway, environment variables, logging

The hardest part is OAuth authentication - once you get the right token with actor=app, everything else flows naturally.

Essential Tips

Always use OAuth token from actor=app flow (not personal API key) ✅ Respond to webhooks within 5 seconds (use setImmediate) ✅ Use ephemeral activities for progress updates ✅ Test with real webhook payloads (don't trust docs alone) ✅ Deploy to always-on hosting (Railway, not serverless)

Resources

Official Documentation:

Community:

Conclusion

You've now built a production-ready Linear agent that can analyze issues, apply labels, and communicate with users - all in real-time. The Linear Agent API opens up endless possibilities for automation and AI integration in your workflow.

More Posts