website/src/content/posts/2025-05-28-building-linear-agents-in-node-js-and-rivet-full-walkthrough-and-starter-kit/page.mdx
import imgAuthLinear from "./auth-linear.png"; import imgAppSetup from "./app-setup.png";
In this guide, you'll learn how to build an AI-powered Linear agent that automates development tasks by generating code based on issue descriptions. You'll build a complete application that authenticates with Linear, receives webhooks, and responds to assignments + comments.
This project is built with:
The core functionality is in src/actors/issue-agent.ts, where the agent handles different types of Linear events and interfaces with the LLM.
The Linear integration works through the following steps:
The system exposes three endpoints:
GET /connect-linear: Initiates the Linear OAuth flow (src/server/index.ts)GET /oauth/callback/linear: OAuth callback endpoint (src/server/index.ts)POST /webhook/linear: Receives Linear webhook events (src/server/index.ts)And maintains state using three main Rivet Actors:
We'll dive in to how these work together next.
Before our agent can interact with a Linear workspace, we need to set up authentication using OAuth 2.0. This allows users to securely authorize our agent in their workspace without sharing their Linear credentials. Our app appears as a team member that can be mentioned and assigned to issues.
Step 1: Initial OAuth Request
The user is directed to GET /connect-linear (src/server/index.ts) to initiate the OAuth flow:
oauthSession Rivet Actorrouter.get("/connect-linear", async (c) => {
// Setup session with a unique ID and nonce for security
const sessionId = crypto.randomUUID();
const nonce = openidClient.randomNonce();
const oauthState = btoa(
JSON.stringify({ sessionId, nonce } satisfies OAuthExpectedState),
);
// Create an actor to track this OAuth session
await actorClient.oauthSession.create(sessionId, {
input: { nonce, oauthState },
});
// Build redirect URL to authorize the agent with Linear
const parameters: Record<string, string> = {
redirect_uri: LINEAR_OAUTH_REDIRECT_URI,
state: oauthState,
scope: "read write app:assignable app:mentionable",
actor: "app", // This is a Linear "actor", not to be confused with Rivet Actor
};
const redirectTo: URL = openidClient.buildAuthorizationUrl(
openidConfig,
parameters,
);
return c.redirect(redirectTo.href);
});
For our OAuth scopes, we request:
read - Read access to the workspace datawrite - Write access to modify issues and commentsapp:assignable - Allows the agent to be assigned to issuesapp:mentionable - Allows the agent to be mentioned in comments and issuesStep 2: User Authentication
The user is redirected to Linear and then:
Step 3: OAuth Callback
When the user completes authentication, Linear redirects to our callback URL with an authorization code:
linearAppUser Rivet Actorrouter.get("/oauth/callback/linear", async (c) => {
const stateRaw = c.req.query("state");
const state = JSON.parse(atob(stateRaw!)) as OAuthExpectedState;
// Validate state with our OAuth session actor
const expectedState = await actorClient.oauthSession
.get(state.sessionId)
.getOAuthState();
// Exchange code for access token
const tokens: openidClient.TokenEndpointResponse =
await openidClient.authorizationCodeGrant(
openidConfig,
new URL(c.req.url),
{ expectedState },
);
// Get the app user ID (unique ID for this agent in the workspace)
const linearClient = new LinearClient({ accessToken: tokens.access_token });
const viewer = await linearClient.viewer;
const appUserId = viewer.id;
// Save the access token in the linearAppUser actor
await actorClient.linearAppUser
.getOrCreate(appUserId)
.setAccessToken(tokens.access_token);
return c.text(`Successfully linked with app user ID ${appUserId}`);
});
After OAuth completes, our agent is fully integrated with the Linear workspace:
Once a user has authorized our application in their Linear workspace, Linear sends webhook events to our endpoint whenever something happens that involves our agent.
The server parses these events and routes them to the appropriate handlers in src/actors/issue-agent.ts:
issueMention: Triggered when the agent is mentioned in an issue descriptionissueEmojiReaction: Triggered when someone reacts with an emoji to an issueissueCommentMention: Triggered when the agent is mentioned in a comment (our agent reacts with 👀 and generates a response)issueCommentReaction: Triggered when someone reacts to a comment where the agent is mentionedissueAssignedToYou: Triggered when an issue is assigned to the agent (updates status to "started" and generates code)issueUnassignedFromYou: Triggered when an issue is unassigned from the agentissueNewComment: Triggered when a new comment is added to an issue the agent is involved withissueStatusChanged: Triggered when the status of an issue changesHere's how we handle webhooks in our server:
router.post("/webhook/linear", async (c) => {
const rawBody = await c.req.text();
// Verify signature to validate this is sent from Linear
const signature = c.req.header("linear-signature");
const computedSignature = crypto
.createHmac("sha256", LINEAR_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (signature !== computedSignature) {
throw new Error("Signature does not match");
}
// Send event to the agent
const event: LinearWebhookEvent = JSON.parse(rawBody);
if (event.type === "AppUserNotification") {
const notification = event.notification;
const issueId = event.notification.issueId;
// Get or create agent for this issue
const issueAgent = actorClient.issueAgent.getOrCreate(issueId, {
createWithInput: { issueId },
});
// Forward event
switch (notification.type) {
case "issueMention":
issueAgent.issueMention(event.appUserId, notification.issue);
break;
case "issueCommentMention":
issueAgent.issueCommentMention(
event.appUserId,
notification.issue,
notification.comment!,
);
break;
case "issueAssignedToYou":
issueAgent.issueAssignedToYou(
event.appUserId,
notification.issue,
);
break;
// Additional cases handled here...
}
}
return c.text("ok");
});
Now that we are handling webhook events correctly, we can implement the functionality of our agent.
The issue agent is the brain of our application. It handles Linear events and generates AI responses. Importantly, it maintains conversation state by storing message history, allowing it to have context-aware conversations with users.
import { actor } from "actor-core";
import type { CoreMessage } from "ai";
interface IssueAgentState {
messages: CoreMessage[];
}
export const issueAgent = actor({
state: {
messages: [],
} as IssueAgentState,
actions: {
// Simple implementation of the issue assignment handler
issueAssignedToYou: async (c, appUserId: string, issue: WebhookIssue) => {
// Get the Linear client for this app user
const linearClient = await buildLinearClient(appUserId);
// Generate response based on the issue description
const fetchedIssue = await linearClient.issue(issue.id);
const response = await prompt(
c,
`I've been assigned to issue: "${issue.title}". The description is:\n${fetchedIssue.description}`
);
// Post the response as a comment
await linearClient.createComment({
issueId: issue.id,
body: response,
});
},
// Additional handlers for other events...
},
});
When generating responses, the agent calls the AI SDK with a history of all messages:
async function prompt(c: ActionContextOf<typeof issueAgent>, content: string) {
// Add the user's message to the conversation history
c.state.messages.push({ role: "user", content });
// Generate a response using the full conversation history
const { text, response } = await generateText({
model: anthropic("claude-4-opus-20250514"),
system: SYSTEM_PROMPT,
messages: c.state.messages,
});
// Add the AI's response to the conversation history
c.state.messages.push(...response.messages);
return text;
}
Now the authentication, webhooks, and agents are all wired up correctly, we can see everything working together in action:
<iframe className="mx-auto" width="560" height="560" src="https://www.youtube.com/embed/GNsKmW6_44M" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen> </iframe>Before getting started with building your own Linear agent, make sure you have:
Clone the repository and navigate to the example:
git clone https://github.com/rivet-dev/engine.git
cd rivet/examples/linear-agent-starter
Install dependencies:
npm install
Set up ngrok for webhook and OAuth callback handling:
# With a consistent URL (recommended)
ngrok http 5050 --url=YOUR-NGROK-URL
# Or without a consistent URL
ngrok http 5050
Create a Linear OAuth application:
https://YOUR-NGROK-URL/oauth/callback/linear (replace YOUR-NGROK-URL with your actual ngrok URL)
https://YOUR-NGROK-URL/webhook/linear (use the same ngrok URL)
Create a .env.local file with your credentials:
LINEAR_OAUTH_CLIENT_ID=<client id>
LINEAR_OAUTH_CLIENT_AUTHENTICATION=<client secret>
LINEAR_OAUTH_REDIRECT_URI=https://YOUR-NGROK-URL/oauth/callback/linear
LINEAR_WEBHOOK_SECRET=<webhook signing secret>
ANTHROPIC_API_KEY=<your_anthropic_api_key>
Remember to replace YOUR-NGROK-URL with your actual ngrok URL (without the https:// prefix).
Run the development server:
npm run dev
The server will start on port 5050. Visit http://127.0.0.1:5050/connect-linear to add the agent to your workspace.
The core of the agent is in src/actors/issue-agent.ts. You can customize:
Event Handlers: Modify actions for different Linear events:
issueMention: When the agent is mentioned in an issue descriptionissueEmojiReaction: When someone reacts with an emoji to an issueissueCommentMention: When the agent is mentioned in a commentissueCommentReaction: When someone reacts to a comment where the agent is mentionedissueAssignedToYou: When an issue is assigned to the agentissueUnassignedFromYou: When an issue is unassigned from the agentissueNewComment: When a new comment is added to an issue the agent is involved withissueStatusChanged: When the status of an issue changesFor example, you could implement intelligent emoji reactions:
async issueEmojiReaction(c, appUserId, issue, emoji) {
const linearClient = await buildLinearClient(appUserId);
// Respond with the same emoji as a form of acknowledgment
await linearClient.createReaction({
issueId: issue.id,
emoji: emoji,
});
// Or conditionally respond based on specific emojis
if (emoji === "🔥") {
await linearClient.createComment({
issueId: issue.id,
body: "Thanks for the encouragement! I'll prioritize this task.",
});
}
}
AI Prompt: Customize the system prompt to change the agent's behavior:
const SYSTEM_PROMPT = `
You are a code generation assistant for Linear. Your job is to:
1. Read issue descriptions and generate appropriate code solutions
2. Iterate on your code based on comments and feedback
3. Provide brief explanations of your implementation
When responding:
- Always provide the full requested code
- Do not exclude parts of the code, always include the full code
- Focus on delivering working code that meets requirements
- Keep explanations concise and relevant
- If no language is specified, use TypeScript
Your goal is to save developers time by providing ready-to-implement solutions.
`;
AI Model: Change the model used for generating responses:
const { text, response } = await generateText({
model: anthropic("claude-4-opus-20250514"), // Change to your preferred model
system: SYSTEM_PROMPT,
messages: c.state.messages,
});
When building a Linear agent, consider these practices for a better developer experience:
You can find the complete source code for this Linear agent starter kit on GitHub: