An OpenClaw plugin that turns Linear's Agent Sessions into fully autonomous AI agent runs. When someone @mentions or delegates an issue to your agent in Linear, this plugin receives the webhook, spins up an OpenClaw agent, and gives it a rich set of tools to manage issues, communicate progress, delegate work, and close tasks — all without leaving Linear.
- How It Works
- Features
- Prerequisites
- Installation
- Linear App Setup
- Plugin Configuration
- Webhook Setup
- Use Cases
- Agent API Reference
- Architecture
- Development
- Troubleshooting
- License
┌──────────┐
│ Linear │
│ Workspace│
└────┬─────┘
│ Webhook (AgentSession / Comment)
▼
┌────────────────┐
│ This Plugin │
│ │
│ 1. Verify HMAC │
│ 2. Resolve │
│ session ID │
│ 3. Build │
│ enriched │
│ prompt │
│ 4. Issue │
│ per-session │
│ API token │
└───────┬────────┘
│ callGateway({ method: "agent", ... })
▼
┌────────────────┐
│ OpenClaw Agent │
│ │
│ Reads issue, │◄──── POST /plugins/linear/api
│ writes code, │ (bearer token auth)
│ posts updates │────► Linear GraphQL API
│ to Linear │
└────────────────┘
- A user @mentions the agent or delegates an issue in Linear
- Linear sends a webhook to this plugin
- The plugin verifies the HMAC signature, resolves the agent session, and builds an enriched prompt containing the issue context and a full API reference
- An OpenClaw agent is launched with that prompt and a short-lived bearer token
- During execution, the agent calls back to the plugin's API proxy to post thoughts, update plans, create sub-issues, delegate, query data, and post final responses
- When the agent finishes, the token is revoked and the response is posted to Linear
- Full Linear Agent Protocol — implements
created,prompted,stopsignal, agent plans, activities (thought/action/elicitation/response/error), proactive sessions - Rich Agent API — during execution the agent can manage issues, post activities, update session plans, delegate work, query issue/team details, and more
- Session Deduplication — prevents duplicate agent runs when Linear sends both AgentSession and Comment webhooks for the same event
- Close Intent Detection — recognizes natural-language close commands in English and Russian ("close this task", "закрой задачу") and fast-paths them without a full agent run
- Per-Session Security — each agent run gets a unique cryptographic bearer token scoped to its session; revoked on completion
- Issue Policies — automatically moves issues to "started" state and delegates to the app user on session creation
- Multi-Repo Routing — maps Linear teams and projects to specific repository directories
- Elicitation with Select — the agent can present clickable option lists to users via the
selectsignal - External URL Linking — attaches external links (e.g. CI dashboard, PR) to the Linear session
- Auto-Detection of Base URL — works behind Tailscale or any reverse proxy; captures the public URL from the first webhook
Hostheader
- Node.js >= 18
- A running OpenClaw gateway instance
- A Linear workspace with admin access (to install an OAuth application)
- A publicly reachable URL for webhooks (Tailscale, ngrok, cloud deploy, etc.)
# From npm (when published)
npm install linear-agent-bridge
# Or clone and build from source
git clone https://github.com/tokezooo/linear-agent-bridge.git
cd linear-agent-bridge
npm install
npm run buildThe plugin registers itself with OpenClaw via the openclaw field in package.json:
{
"openclaw": {
"extensions": ["./dist/index.js"]
}
}- Go to Linear Settings > API > Applications > Create new
- Set a recognizable name (this is how users will see the agent in mentions and filters)
- Enable Webhooks
- Under webhook events, select Agent session events
- Set the webhook URL to:
https://<your-host>/plugins/linear/linear
Install the app into your workspace using the OAuth flow with actor=app:
https://linear.app/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT&response_type=code&scope=read,write,issues:create,comments:create,app:assignable,app:mentionable&actor=app
Key scopes:
| Scope | Purpose |
|---|---|
read, write |
Core issue/comment access |
issues:create |
Create issues and sub-issues |
comments:create |
Post comments |
app:assignable |
Allow delegation to the agent |
app:mentionable |
Allow @mentioning the agent |
After the user authorizes your app, Linear redirects with ?code=....
This plugin can accept the code, exchange it, and persist tokens automatically:
GET /plugins/linear/oauth/callback?code=...(browser redirect target)POST /plugins/linear/oauth/exchangewith JSON{ "code": "..." }
The token set is stored in linearTokenStorePath (default ~/.openclaw/workspace/.pi/linear-oauth.json) with restrictive file permissions, and refresh is attempted automatically when the access token expires.
In your Linear application settings, copy the Webhook signing secret. This is used for HMAC-SHA256 signature verification of incoming webhooks.
Configure the plugin in your OpenClaw config under the plugin's section. All options are defined in openclaw.plugin.json.
| Option | Type | Description |
|---|---|---|
linearWebhookSecret |
string |
Webhook signing secret for HMAC verification |
Authentication requires one of these modes:
- Static token mode: set
linearApiKey - OAuth automation mode: set
linearOauthClientId,linearOauthClientSecret,linearOauthRedirectUri
| Option | Type | Default | Description |
|---|---|---|---|
devAgentId |
string |
"dev" |
OpenClaw agent ID to handle Linear issues |
defaultDir |
string |
— | Default repository directory for agent work |
| Option | Type | Default | Description |
|---|---|---|---|
delegateOnCreate |
boolean |
true |
Auto-delegate issues to the app user when a session is created |
startOnCreate |
boolean |
true |
Move issues to "started" workflow state on session creation |
| Option | Type | Description |
|---|---|---|
repoByTeam |
object |
Map Linear team keys to repository directories. Example: { "ENG": "/home/code/backend", "WEB": "/home/code/frontend" } |
repoByProject |
object |
Map Linear project keys to repository directories. Takes precedence over repoByTeam |
| Option | Type | Default | Description |
|---|---|---|---|
enableAgentApi |
boolean |
true |
Enable the API proxy that agents call during execution |
apiBaseUrl |
string |
auto-detected | Override the auto-detected base URL for agent API callbacks |
linearTokenStorePath |
string |
~/.openclaw/workspace/.pi/linear-oauth.json |
Workspace-local OAuth token store path (written with 0600) |
apiCorsOrigins |
string[] |
— | Allowed origins for CORS on /plugins/linear/api. Use ["*"] to allow any origin |
apiCorsAllowCredentials |
boolean |
false |
Adds Access-Control-Allow-Credentials: true when origin is explicitly allowed |
| Option | Type | Default | Description |
|---|---|---|---|
strictAddressing |
boolean |
false |
Process only explicitly addressed events (mentions, delegation, thread replies) |
mentionHandle |
string |
— | App mention handle used in strict mode (example: personal-openclaw) |
| Option | Type | Description |
|---|---|---|
externalUrlBase |
string |
URL template for session links. Supports {session} and {issue} placeholders. Example: https://dash.example.com/sessions/{session} |
externalUrlLabel |
string |
Label for external links (default: "OpenClaw session") |
| Option | Type | Description |
|---|---|---|
notifyChannel |
string |
Channel for delivery notifications (e.g. "discord") |
notifyTo |
string |
Target for notifications (e.g. "channel:123456") |
notifyAccountId |
string |
Account ID for notifications |
{
"linearWebhookSecret": "whsec_...",
"linearOauthClientId": "...",
"linearOauthClientSecret": "...",
"linearOauthRedirectUri": "https://your-host/plugins/linear/oauth/callback",
"linearTokenStorePath": "/home/ubuntu/.openclaw/workspace/.pi/linear-oauth.json",
"devAgentId": "dev",
"defaultDir": "/home/projects/main-repo",
"repoByTeam": {
"ENG": "/home/projects/backend",
"WEB": "/home/projects/frontend"
},
"delegateOnCreate": true,
"startOnCreate": true,
"strictAddressing": true,
"mentionHandle": "personal-openclaw",
"enableAgentApi": true,
"apiCorsOrigins": ["https://linear.app", "https://linear.com"],
"apiCorsAllowCredentials": false,
"externalUrlBase": "https://dash.example.com/sessions/{session}"
}The plugin registers a POST endpoint at /plugins/linear/linear.
- HMAC-SHA256 Signature Verification — every incoming webhook is verified against the
linearWebhookSecretusing thelinear-signatureheader - Stale Webhook Rejection — webhooks older than 60 seconds are rejected
- Immediate 202 Response — webhook processing happens asynchronously after responding to Linear
| Event Type | Action | Result |
|---|---|---|
AgentSession created |
New session | Full agent run with enriched prompt |
AgentSession prompted |
Follow-up message | Agent continues with new context |
| Comment (on agent thread) | Follow-up | Resolved to session, triggers prompted |
Signal stop |
Halt | Agent posts stop confirmation, no run |
| Close intent ("close task") | Fast-path | Issue closed directly, no agent run |
PermissionChangeandOAuthAppevents (logged only)AppUserNotificationevents- Self-authored comments (prevents feedback loops)
- System echo messages (e.g. "Starting work on...", "Agent run failed:")
- Empty prompts
- Duplicate events within the dedup window (5 seconds)
Delegate an issue to the agent in Linear. The agent receives the full issue context (title, description, labels, comments), writes code, creates sub-issues for subtasks, posts progress updates, and closes the issue when done.
@mention the agent in a comment asking for review. The agent reads the issue context, examines the linked code, and posts structured feedback as a response activity.
The agent can delegate sub-tasks to other agents or reassign issues to human team members:
{ "action": "delegate/assign", "issueId": "...", "delegateId": "other-agent-id" }Ask the agent to break down a large issue. It creates sub-issues with appropriate priorities and links them to the parent:
{ "action": "issue/create-sub-issue", "title": "Implement auth middleware", "priority": 1 }The agent shows real-time progress via session plans — structured checklists visible in the Linear UI:
{
"action": "session/plan",
"plan": [
{ "content": "Analyze issue requirements", "status": "completed" },
{ "content": "Implement solution", "status": "inProgress" },
{ "content": "Write tests", "status": "pending" },
{ "content": "Post summary", "status": "pending" }
]
}The agent can ask the user to choose between options using the select signal:
{
"action": "activity/elicitation",
"body": "Which approach should I take?",
"signal": "select",
"signalMeta": {
"options": [
{ "value": "Refactor the existing module" },
{ "value": "Write a new implementation from scratch" }
]
}
}The agent can create new sessions on other issues or comments without being explicitly delegated:
{ "action": "session/create-on-issue", "issueId": "issue-uuid" }During execution, the agent makes HTTP POST requests to a single endpoint. All requests use bearer token authentication and a JSON body with an action field.
Endpoint: POST <apiBaseUrl>
Auth: Authorization: Bearer <per-session-token>
Content-Type: application/json
Every request body must include an action field.
| Field | Type | Required | Description |
|---|---|---|---|
teamId |
string |
No (defaults to current) | Team to create issue in |
title |
string |
Yes | Issue title |
description |
string |
No | Issue description (Markdown) |
priority |
number |
No | Priority 0-4 (0 = no priority, 1 = urgent, 4 = low) |
labelIds |
string[] |
No | Label IDs to attach |
assigneeId |
string |
No | Assignee user ID |
parentId |
string |
No | Parent issue ID (creates sub-issue) |
stateId |
string |
No | Initial workflow state |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue to update |
title |
string |
No | New title |
description |
string |
No | New description |
stateId |
string |
No | New workflow state |
priority |
number |
No | New priority |
labelIds |
string[] |
No | Replace labels |
assigneeId |
string |
No | New assignee |
delegateId |
string |
No | New delegate |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue to close |
Resolves the team's "completed" workflow state and transitions the issue. Returns alreadyClosed: true if the issue is already completed or canceled.
| Field | Type | Required | Description |
|---|---|---|---|
title |
string |
Yes | Sub-issue title |
parentId |
string |
No (defaults to current) | Parent issue |
description |
string |
No | Description |
priority |
number |
No | Priority 0-4 |
labelIds |
string[] |
No | Labels |
assigneeId |
string |
No | Assignee |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Source issue |
relatedIssueId |
string |
Yes | Target issue |
type |
string |
Yes | One of: blocks, blocked_by, related, duplicate |
Activities are how the agent communicates with users in the Linear session UI.
| Field | Type | Required | Description |
|---|---|---|---|
body |
string |
Yes | Markdown text |
ephemeral |
boolean |
No | If true, shown temporarily and replaced by next activity |
| Field | Type | Required | Description |
|---|---|---|---|
activityAction |
string |
Yes | Action verb (e.g. "Searching", "Building") |
parameter |
string |
No | Subject of the action |
result |
string |
No | Result (Markdown) |
| Field | Type | Required | Description |
|---|---|---|---|
body |
string |
Yes | Question text |
signal |
string |
No | Set to "select" to show option buttons |
signalMeta |
object |
No | { options: [{ value: "..." }] } — list of choices |
| Field | Type | Required | Description |
|---|---|---|---|
body |
string |
Yes | Final response (Markdown) |
Marks the session as having a posted response. The handler will skip auto-posting the agent's text output.
| Field | Type | Required | Description |
|---|---|---|---|
body |
string |
Yes | Error description |
| Field | Type | Required | Description |
|---|---|---|---|
plan |
array |
Yes | Array of plan steps |
Each step: { content: "Step description", status: "pending" | "inProgress" | "completed" | "canceled" }
Note: Replaces the entire plan each time. Always include all steps.
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
Yes | Issue to create session on |
| Field | Type | Required | Description |
|---|---|---|---|
commentId |
string |
Yes | Comment to create session on |
| Field | Type | Required | Description |
|---|---|---|---|
url |
string |
Yes | URL to attach |
label |
string |
No | Link label (default: "Link") |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue to delegate |
delegateId |
string |
Yes | Target agent or user ID |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue to reassign |
assigneeId |
string |
Yes | New assignee ID |
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue ID |
Returns: labels, state, assignee, delegate, parent, children, relations, recent comments.
| Field | Type | Required | Description |
|---|---|---|---|
teamId |
string |
No (defaults to current) | Team ID |
Returns: workflow states, labels, members.
| Field | Type | Required | Description |
|---|---|---|---|
issueId |
string |
No (defaults to current) | Issue for context |
candidateRepositories |
array |
Yes | [{ hostname, repositoryFullName }] |
Returns ranked suggestions with confidence scores.
No parameters. Returns the authenticated app's user ID.
index.ts ← Entry point: registers HTTP routes
├── src/
│ ├── types.ts ← Shared TypeScript interfaces
│ ├── config.ts ← Plugin config normalization
│ ├── util.ts ← HTTP/JSON helpers
│ ├── linear-client.ts ← Single gateway for all Linear GraphQL calls
│ ├── graphql/
│ │ ├── queries.ts ← GraphQL query strings
│ │ └── mutations.ts ← GraphQL mutation strings
│ ├── webhook/
│ │ ├── handler.ts ← Main webhook handler + agent orchestration
│ │ ├── validation.ts ← HMAC-SHA256 signature verification
│ │ ├── session-resolver.ts ← Session ID lookup (direct → cache → GraphQL)
│ │ ├── message-builder.ts ← Agent prompt construction
│ │ ├── response-parser.ts ← Parse agent output into response text
│ │ ├── issue-policy.ts ← Auto-start and auto-delegate policies
│ │ ├── close-intent.ts ← Natural language close detection
│ │ └── skip-filter.ts ← System echo and self-comment filtering
│ ├── api/
│ │ ├── router.ts ← API proxy router (bearer token auth)
│ │ ├── base-url.ts ← Auto-detect public URL from Host header
│ │ ├── issue-ops.ts ← Issue CRUD operations
│ │ ├── activity-ops.ts ← Agent activity posting
│ │ ├── session-ops.ts ← Session management (plans, proactive creation)
│ │ ├── delegation-ops.ts ← Issue delegation and reassignment
│ │ └── query-ops.ts ← Read-only queries (issue, team, viewer)
│ └── agent/
│ ├── session-token.ts ← Per-run bearer token lifecycle
│ ├── context-builder.ts ← Enriched prompt with API documentation
│ ├── response-tracker.ts ← Tracks whether agent already posted a response
│ └── plan-manager.ts ← In-memory plan state per session
| Pattern | Description |
|---|---|
| Single Linear Gateway | All GraphQL communication goes through callLinear() which handles auth, errors, and logging |
| Per-Session Tokens | Each agent run gets a unique crypto.randomBytes(32) bearer token, revoked on completion |
| Response Deduplication | If the agent posts a response via the API, the handler skips auto-posting the text output |
| Cascading Session Resolution | Direct field → in-memory cache → GraphQL query with retry (120ms/350ms/800ms backoff) |
| Side-Effect Registration | API handlers register themselves via registerApiHandler() and are imported in index.ts |
| Dedup Window | Prevents double agent runs when Linear sends both AgentSession + Comment webhooks (5s window) |
npm run build # Runs tsc, outputs to dist/- TypeScript with
strict: true, targeting ES2022 - ESM (
"type": "module"in package.json) - Node16 module resolution
- Zero runtime dependencies (only
@types/nodeandtypescriptas dev deps)
- Create a file in
src/api/(e.g.my-ops.ts) - Import and use
registerApiHandler:
import { registerApiHandler } from "./router.js";
import { sendJson } from "../util.js";
registerApiHandler("/my/action", async ({ api, cfg, context, body, res }) => {
// context.sessionId, context.issueId, context.teamId are available
// body contains the parsed JSON request
sendJson(res, 200, { ok: true });
});- Add a side-effect import in
index.ts:
import "./src/api/my-ops.js";The handler is now available as { "action": "my/action" } through the API proxy.
- Verify the URL in Linear application settings matches
https://<host>/plugins/linear/linear - Ensure your host is publicly reachable (check with
curl) - Check that "Agent session events" is enabled in the Linear app webhook settings
- Verify
linearWebhookSecretmatches the signing secret from Linear app settings - Check that the webhook is not stale (>60 seconds old) — clock sync issues can cause this
- Check that
linearApiKeyis a valid OAuth token with the required scopes - Verify the agent ID in
devAgentIdmatches a configured OpenClaw agent - Check OpenClaw gateway logs for errors
- If
apiBaseUrlis not set, the URL is auto-detected from the first webhook'sHostheader. Ensure your reverse proxy forwards the correctHost - For Tailscale setups, the
Hostheader with.ts.netdomain is used automatically - Set
apiBaseUrlexplicitly to bypass auto-detection
- The plugin automatically filters out comments authored by the app itself using the
viewerquery - If you see loops, verify that the
linearApiKeybelongs to the same OAuth application that sends the webhooks