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
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.
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.
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!
Let's create our agent. We'll use TypeScript, Express for webhooks, the Linear SDK, and OpenAI for intelligent analysis.
mkdir orla && cd orla
npm init -y
npm install @linear/sdk express dotenv openai axios
npm install -D typescript @types/node @types/express tsx
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"]
}
Update package.json
:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Create .env
:
LINEAR_API_KEY= # We'll fill this in later
OPENAI_API_KEY= # Your OpenAI API key
PORT=3000
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
This is the most critical part. Linear agents require proper OAuth authentication - personal API keys won't work!
Go to Linear → Settings → API → Applications
Click "Create new OAuth application"
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
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
Enable Webhooks:
✅ Agent session events
Copy your Client ID and Client Secret
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!
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;
}
}
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.
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}`);
});
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;
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;
}
}
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);
}
}
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'}`,
}
);
}
}
}
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! 🚀
`);
});
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(', ')}`);
}
}
Railway is perfect for webhook-based apps - it's always-on (no cold starts), easy to deploy, and has a generous free tier.
✅ Always-on (critical for webhooks!)
✅ Automatic HTTPS
✅ Built-in logging
✅ Simple deployment
✅ $5 free credit/month
npm install -g @railway/cli
railway login
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"]
railway init
# Select "Empty Project"
# Name it "orla"
railway variables --set "LINEAR_API_KEY=lin_oauth_your_token_here"
railway variables --set "OPENAI_API_KEY=sk-your_key_here"
git init
git add .
git commit -m "Initial commit"
git push # Or use: railway up
railway domain
# If no domain: railway domain create
You'll get a URL like: https://orla-production.up.railway.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
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"
In Linear OAuth app settings:
Go to Team access
Change to "All public teams and select private teams..."
Or select specific teams
Click Update
In Linear:
Open any issue
Click Assignee dropdown
Look for Orla under "Agents"
If you don't see it, try toggling the team access settings again.
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:
Orla sends "Analyzing issue..." (ephemeral)
"Analyzing: Issue content..." (ephemeral)
"Adding: 2 label(s)..." (ephemeral)
Final response with reasoning (permanent)
Labels applied to the issue
Comment notification
railway logs
You should see:
📨 Received webhook: created for session fb7e22db...
✅ Analyzing issue...
✅ Labels applied
Problem: Completed OAuth flow, but Orla doesn't appear in dropdown
Solutions:
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
Check authorization URL had actor=app
Re-authorize with the correct URL
Verify team access settings
Linear → Applications → Orla → Team Access
Try toggling between settings and saving
Check scopes include app:assignable
Linear → Applications → Orla → Permissions
Problem: Assign to Orla, nothing happens
Solutions:
Test endpoint manually:
curl -X POST https://your-domain.com/webhooks/linear \
-H "Content-Type: application/json" \
-d '{"test": "ping"}'
# Should return: {"received": true}
Check Railway logs:
railway logs | grep "Webhook received"
Verify webhook URL in Linear:
Must be: https://your-domain.com/webhooks/linear
Must be publicly accessible
Check "Agent session events" is enabled:
Linear → Applications → Orla → Webhooks
Problem: Webhook received, but no activities in Linear
Solutions:
Verify using OAuth token (not personal API key)
Check agentSessionId is valid UUID:
console.log('Session ID:', agentSession.id);
// Should be UUID format: fb7e22db-b8af-4f17-94ff-177807ecf8d8
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
}
Problem: Deploy fails with "cannot find module" errors
Solutions:
Ensure package-lock.json is committed:
npm install # Generates package-lock.json
git add package-lock.json
git commit -m "Add lockfile"
Test build locally:
npm run build
# Fix any TypeScript errors before deploying
Congratulations! You've built a working Linear agent. Here are some ideas to extend it:
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
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
Building a Linear agent teaches you:
OAuth flows - Understanding actor authorization and token types
Webhook design - Responding quickly, processing async
Real-time communication - Using Agent Activities effectively
AI integration - Combining GPT-4 with structured APIs
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.
✅ 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)
Official Documentation:
Community:
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.