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:
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:
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!
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
read - Read workspace datawrite - Write to workspaceissues:create - Create/update issuescomments:create - Create commentsapp:assignable - CRITICAL! Makes app assignableHere’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.
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:
https://orla-production.up.railway.app/auth/callbackhttps://orla-production.up.railway.app/webhooks/linearVisit 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:
In Linear:
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:
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:
curl -X POST https://api.linear.app/graphql
-H “Authorization: YOUR_TOKEN”
-d ’{“query”:”{ viewer { name email } }”}‘
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**:
```bash
curl -X POST https://your-domain.com/webhooks/linear \
-H "Content-Type: application/json" \
-d '{"test": "ping"}'
# Should return: {"received": true}
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**:
```typescript
console.log('Session ID:', agentSession.id);
// Should be UUID format: fb7e22db-b8af-4f17-94ff-177807ecf8d8
// 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**:
```bash
npm install # Generates package-lock.json
git add package-lock.json
git commit -m "Add lockfile"
npm run build
## **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
```typescript
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:
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.
Book a chat