Skip to content

Commit e13e6e9

Browse files
committed
docs: add linear-agent-starter example (#2482)
<!-- Please make sure there is an issue that this PR is correlated to. --> ## Changes <!-- If there are frontend changes, please include screenshots. -->
1 parent 13a989c commit e13e6e9

23 files changed

Lines changed: 8569 additions & 1772 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.actorcore
2+
node_modules
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Linear Agent Starter
2+
3+
A starter template for building a Linear agent using [Rivet](https://rivet.gg) and [ActorCore](https://actorcore.org). This example agent helps developers by generating code based on issue descriptions and requirements.
4+
5+
The agent can:
6+
- Act on issues when assigned to it
7+
- React to comment mentions with 👀 and generate code-focused responses
8+
9+
This simple example serves as a starting point for building more sophisticated Linear agents. For full documentation on Linear Agents, see the [Linear Agents documentation](https://linear.app/developers/agents).
10+
11+
## Getting Started
12+
13+
## TL;DR
14+
15+
See `src/actors/issue-agent.ts` for the brains of the whole thing.
16+
17+
### Prerequisites
18+
19+
- Node.js (v18+)
20+
- Linear account and API access
21+
- Anthropic API key (can be swapped for any [AI SDK provider](https://ai-sdk.dev/docs/foundations/providers-and-models))
22+
- [ngrok](https://ngrok.com/download) for exposing your local server to the internet
23+
24+
### Setup and Configuration
25+
26+
1. Clone the repository and navigate to the example:
27+
```bash
28+
git clone https://github.com/rivet-gg/rivet.git
29+
cd rivet/examples/linear-agent-starter
30+
```
31+
32+
2. Install dependencies:
33+
```bash
34+
npm install
35+
```
36+
3. Set up ngrok for webhook and OAuth callback handling:
37+
38+
```bash
39+
# With a consistent URL (recommended)
40+
ngrok http 5050 --url=YOUR-NGROK-URL
41+
42+
# Or without a consistent URL
43+
ngrok http 5050
44+
```
45+
46+
4. Create a Linear OAuth application:
47+
1. Go to to [Linear's create application page](https://linear.app/settings/api/applications/new)
48+
2. Enter your _Application name_, _Developer name_, _Developer URL_, _Description_, and _GitHub username_ for your agent
49+
3. Set _Callback URL_ to `https://YOUR-NGROK-URL/oauth/callback/linear` (replace `YOUR-NGROK-URL` with your actual [ngrok URL](https://ngrok.com/docs/universal-gateway/domains/))
50+
* This URL is where Linear will redirect after OAuth authorization
51+
4. Enable webhooks
52+
5. Set _Webhook URL_ to `https://YOUR-NGROK-URL/webhook/linear` (use the same ngrok URL)
53+
* This URL is where Linear will send events when your agent is mentioned or assigned
54+
6. Enable _Inbox notifications_ webhook events
55+
7. Create the application to get your _Client ID_, _Client Secret_, and webhook _Signing secret_
56+
57+
<p align="center">
58+
<img alt="Linear App Setup" src="./media/app-setup.png" height="600" />
59+
</p>
60+
61+
5. Create a `.env.local` file with your credentials:
62+
```
63+
LINEAR_OAUTH_CLIENT_ID=<client id>
64+
LINEAR_OAUTH_CLIENT_AUTHENTICATION=<client secret>
65+
LINEAR_OAUTH_REDIRECT_URI=https://YOUR-NGROK-URL/oauth/callback/linear
66+
LINEAR_WEBHOOK_SECRET=<webhook signing secret>
67+
ANTHROPIC_API_KEY=<your_anthropic_api_key>
68+
```
69+
70+
Remember to replace `YOUR-NGROK-URL` with your actual ngrok URL (without the https:// prefix).
71+
72+
### Running the Development Server
73+
74+
```bash
75+
npm run dev
76+
```
77+
78+
The server will start on port 5050. Visit http://127.0.0.1:5050/connect-linear to add the agent to your workspace.
79+
80+
### Testing the Agent
81+
82+
Once the agent is installed in your workspace, create a new issue and assign it to the agent. The agent will generate code for you based on your issue.
83+
84+
You can mention the agent in comments to iterate on the code.
85+
86+
## Architecture
87+
88+
This project uses [ActorCore](https://actorcore.org) to manage stateful actors with a [Hono](https://hono.dev/) HTTP server.
89+
90+
### Actors
91+
92+
All actor files are located in the `src/actors/` directory:
93+
94+
- **Issue Agent** (`src/actors/issue-agent.ts`): Handles Linear issue events and generates responses
95+
- **Linear App User** (`src/actors/linear-app-user.ts`): Manages authentication state for the application
96+
- **OAuth Session** (`src/actors/oauth-session.ts`): Handles OAuth flow state
97+
98+
### Server Endpoints
99+
100+
The server implementation is in `src/server/index.ts`:
101+
102+
- `GET /connect-linear`: Initiates the Linear OAuth flow
103+
- `GET /oauth/callback/linear`: OAuth callback endpoint
104+
- `POST /webhook/linear`: Receives Linear webhook events
105+
106+
The server handles the OAuth flow, authenticates with Linear, and routes events to the appropriate actors for processing.
287 KB
Loading
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "linear-agent-starter",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "npx tsx --watch src/server/index.ts",
8+
"dev:check-types": "npx tsc --noEmit --watch",
9+
"check-types": "tsc --noEmit",
10+
"test": "vitest run",
11+
"deploy": "npx @actor-core/cli deploy rivet actors/app.ts"
12+
},
13+
"devDependencies": {
14+
"@actor-core/cli": "0.9.0-rc.1",
15+
"@actor-core/rivet": "0.9.0-rc.1",
16+
"@types/deno": "^2.2.0",
17+
"@types/invariant": "^2",
18+
"@types/node": "^22.13.9",
19+
"actor-core": "0.9.0-rc.1",
20+
"tsx": "^3.12.7",
21+
"typescript": "^5.7.3",
22+
"vitest": "^3.1.1"
23+
},
24+
"stableVersion": "0.8.0",
25+
"dependencies": {
26+
"@actor-core/nodejs": "0.9.0-rc.1",
27+
"@ai-sdk/anthropic": "^1.2.12",
28+
"@ai-sdk/openai": "^1.3.22",
29+
"@linear/sdk": "^40.0.0",
30+
"ai": "^4.3.16",
31+
"dotenv": "^16.5.0",
32+
"hono": "^4.7.0",
33+
"invariant": "^2.2.4",
34+
"openid-client": "^6.5.0"
35+
}
36+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { setup } from "actor-core";
2+
import { oauthSession } from "./oauth-session";
3+
import { issueAgent } from "./issue-agent";
4+
import { linearAppUser } from "./linear-app-user";
5+
import { createClient } from "actor-core/client";
6+
import { PORT, BASE_PATH } from "../config";
7+
8+
export const app = setup({
9+
actors: { issueAgent, oauthSession, linearAppUser },
10+
basePath: BASE_PATH,
11+
});
12+
13+
export type App = typeof app;
14+
15+
export const actorClient = createClient<App>(
16+
`http://127.0.0.1:${PORT}${BASE_PATH}`,
17+
);
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { ActionContextOf, actor } from "actor-core";
2+
import { LinearClient } from "@linear/sdk";
3+
import { actorClient } from "./app";
4+
import { WebhookIssue, WebhookComment } from "../linear-types";
5+
import { CoreMessage, generateText } from "ai";
6+
import { anthropic } from "@ai-sdk/anthropic";
7+
8+
interface IssueAgentState {
9+
messages: CoreMessage[];
10+
}
11+
12+
export const issueAgent = actor({
13+
state: {
14+
messages: [],
15+
} as IssueAgentState,
16+
actions: {
17+
issueMention: async (c, appUserId: string, issue: WebhookIssue) => {
18+
// Do nothing
19+
},
20+
issueEmojiReaction: async (
21+
c,
22+
appUserId: string,
23+
issue: WebhookIssue,
24+
emoji: string,
25+
) => {
26+
// Do nothing
27+
},
28+
issueCommentMention: async (
29+
c,
30+
appUserId: string,
31+
issue: WebhookIssue,
32+
comment: WebhookComment,
33+
) => {
34+
c.log.info("mentioned in comment", {
35+
issue: issue.id,
36+
comment: comment.id,
37+
});
38+
39+
const linearClient = await buildLinearClient(appUserId);
40+
41+
c.log.info("acknowledging comment", {
42+
issue: issue.id,
43+
comment: comment.id,
44+
});
45+
await linearClient.createReaction({
46+
commentId: comment.id,
47+
emoji: "👀",
48+
});
49+
50+
c.log.info("generating response to comment", {
51+
issue: issue.id,
52+
comment: comment.id,
53+
});
54+
const fetchedComment = await linearClient.comment({
55+
id: comment.id,
56+
});
57+
const response = await prompt(
58+
c,
59+
`The user mentioned me in a comment:\n\n\`\`\`\n${comment.body}\n\`\`\``,
60+
);
61+
await linearClient.createComment({
62+
issueId: issue.id,
63+
// Must use the top-most comment ID
64+
parentId: fetchedComment.parentId ?? comment.id,
65+
body: response,
66+
});
67+
},
68+
issueAssignedToYou: async (
69+
c,
70+
appUserId: string,
71+
issue: WebhookIssue,
72+
) => {
73+
c.log.info(`Issue assigned to app: ${issue.title} (${issue.id})`);
74+
const linearClient = await buildLinearClient(appUserId);
75+
76+
// Set issue as started
77+
c.log.info("finding issue state", { issue: issue.id });
78+
const fetchedIssue = await linearClient.issue(issue.id);
79+
const state = await fetchedIssue.state;
80+
if (
81+
state &&
82+
state.type !== "started" &&
83+
state.type !== "completed" &&
84+
state.type !== "canceled"
85+
) {
86+
const states = await linearClient.workflowStates();
87+
const startedState = states.nodes.find(
88+
(s) => s.type === "started",
89+
);
90+
if (startedState) {
91+
c.log.info("updating issue state", {
92+
issue: issue.id,
93+
state: startedState.id,
94+
});
95+
await fetchedIssue.update({ stateId: startedState.id });
96+
} else {
97+
c.log.warn("could not find started state");
98+
}
99+
}
100+
101+
// Generate response
102+
c.log.info("generating response to issue", { issue: issue.id });
103+
const response = await prompt(
104+
c,
105+
`I've been assigned to issue: "${issue.title}". The description is:\n\n\`\`\`\n${fetchedIssue.description}\n\`\`\``,
106+
);
107+
await linearClient.createComment({
108+
issueId: issue.id,
109+
body: response,
110+
});
111+
},
112+
issueCommentReaction: async (
113+
c,
114+
appUserId: string,
115+
issue: WebhookIssue,
116+
comment: WebhookComment,
117+
emoji: string,
118+
) => {
119+
// Do nothing
120+
},
121+
issueUnassignedFromYou: async (
122+
c,
123+
appUserId: string,
124+
issue: WebhookIssue,
125+
) => {
126+
const linearClient = await buildLinearClient(appUserId);
127+
128+
c.log.info("responding to issue unassigned", { issue: issue.id });
129+
await linearClient.createComment({
130+
issueId: issue.id,
131+
body: "I've been unassigned from this issue.",
132+
});
133+
},
134+
issueNewComment: async (
135+
c,
136+
appUserId: string,
137+
issue: WebhookIssue,
138+
comment: WebhookComment,
139+
) => {
140+
// Do nothing
141+
},
142+
issueStatusChanged: async (
143+
c,
144+
appUserId: string,
145+
issue: WebhookIssue,
146+
) => {
147+
// Do nothing
148+
},
149+
},
150+
});
151+
152+
async function buildLinearClient(appUserId: string): Promise<LinearClient> {
153+
const accessToken = await actorClient.linearAppUser
154+
.get(appUserId)
155+
.getAccessToken();
156+
return new LinearClient({ accessToken });
157+
}
158+
159+
const SYSTEM_PROMPT = `
160+
You are a code generation assistant for Linear. Your job is to:
161+
162+
1. Read issue descriptions and generate appropriate code solutions
163+
2. Iterate on your code based on comments and feedback
164+
3. Provide brief explanations of your implementation
165+
166+
When responding:
167+
- Always provide the full requested code
168+
- Do not exclude parts of the code, always include the full code
169+
- Focus on delivering working code that meets requirements
170+
- Keep explanations concise and relevant
171+
- If no language is specified, use TypeScript
172+
173+
Your goal is to save developers time by providing ready-to-implement solutions.
174+
`;
175+
176+
async function prompt(c: ActionContextOf<typeof issueAgent>, content: string) {
177+
c.log.debug("generating text", { messages: c.state.messages });
178+
179+
c.state.messages.push({ role: "user", content });
180+
181+
const { text, response } = await generateText({
182+
model: anthropic("claude-4-opus-20250514"),
183+
system: SYSTEM_PROMPT,
184+
messages: c.state.messages,
185+
});
186+
187+
c.state.messages.push(...response.messages);
188+
189+
return text;
190+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { actor } from "actor-core";
2+
3+
interface LinearAppUserState {
4+
accessToken?: string;
5+
}
6+
7+
export const linearAppUser = actor({
8+
state: {} as LinearAppUserState,
9+
actions: {
10+
setAccessToken: (c, accessToken: string) => {
11+
c.state.accessToken = accessToken;
12+
},
13+
getAccessToken: (c) => {
14+
return c.state.accessToken;
15+
},
16+
},
17+
});

0 commit comments

Comments
 (0)