Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,140 @@ npm run check # lint + typecheck + test

Zero runtime dependencies. TypeScript strict mode. ESM only. 95%+ test coverage.

## Hermes Agent Setup

[Hermes Agent](https://hermes-agent.nousresearch.com/) by Nous Research is an alternative AI agent backend with built-in learning, skills, MCP integration, and 15+ messaging platform support.

### When to use Hermes

- You prefer Hermes's agent capabilities (skills, memory, MCP)
- You want multi-platform reply delivery (Telegram, Discord, Slack, etc.)
- You want Hermes's built-in learning loop and user modeling

### Prerequisites

- [Hermes Agent installed](https://hermes-agent.nousresearch.com/docs/getting-started/installation) and running
- Linear OAuth token (from OpenClaw plugin's `~/.openclaw/plugins/linear-light/token.json`)

### Step 1: Add Linear API token to Hermes

```bash
# Read the token from OpenClaw's token store
TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.openclaw/plugins/linear-light/token.json'))['accessToken'])")

# Add to Hermes env
echo "LINEAR_API_TOKEN=$TOKEN" >> ~/.hermes/.env
```

### Step 2: Install the Linear Workflow Skill

Copy the skill file to Hermes's skills directory:

```bash
mkdir -p ~/.hermes/skills/linear-workflow
cp docs/hermes-skill.md ~/.hermes/skills/linear-workflow/SKILL.md
```

The skill provides Hermes with:
- Linear GraphQL API access via `curl` (comments, status updates, search)
- Project memory workflow (read/write `~/clawd/projects/` files)
- Reply formatting guidelines

### Step 3: Configure Hermes Webhook

In `~/.hermes/config.yaml`, add the webhook platform:

```yaml
platforms:
webhook:
enabled: true
extra:
port: 8644
secret: "your-hermes-webhook-secret"
routes:
linear:
secret: "your-hermes-webhook-secret"
prompt: "{prompt}"
skills: ["linear-workflow"]
deliver: "log"
```

> `deliver: "log"` because Hermes doesn't natively post to Linear.
> The `linear-workflow` skill handles posting comments directly via the Linear GraphQL API.

### Step 4: Enable Hermes Mode in OpenClaw

Update your OpenClaw config:

```json
{
"plugins": {
"entries": {
"linear-light": {
"config": {
"enabled": true,
"webhookSecret": "your-linear-webhook-secret",
"linearClientId": "...",
"linearClientSecret": "...",
"dispatchMode": "hermes",
"hermes": {
"webhookUrl": "http://localhost:8644/webhooks",
"webhookSecret": "your-hermes-webhook-secret",
"routeName": "linear",
"timeoutMs": 15000
}
}
}
}
}
}
```

### Step 5: Restart

```bash
hermes gateway restart
openclaw gateway restart
```

### Step 6: Verify

```bash
# Check Hermes webhook health
curl http://localhost:8644/health

# Check plugin status
curl http://localhost:<openclaw-port>/linear-light/status

# Trigger a test — @mention the agent in a Linear issue
```

### Feature Comparison

| Feature | OpenClaw Mode | Hermes Mode |
|---|---|---|
| HMAC-verified webhooks | ✅ | ✅ |
| Per-issue sessions | ✅ | ✅ |
| Real-time activity streaming | ✅ | ❌ |
| Linear API tools | ✅ (native) | ✅ (via skill) |
| Project memory (git) | ✅ (auto) | ✅ (via skill) |
| Completion loop | ✅ (built-in) | ❌ (use Hermes cron) |
| Auto In Progress | ✅ | ✅ (via skill) |
| Multi-platform reply | ❌ | ✅ (15+) |
| MCP integration | ❌ | ✅ |
| Skills system | ❌ | ✅ |
| Memory/learning | ❌ | ✅ |

### Config Reference (Hermes)

| Option | Type | Default | Description |
|---|---|---|---|
| `dispatchMode` | string | `"openclaw"` | `"openclaw"` or `"hermes"` |
| `hermes.webhookUrl` | string | — | Hermes webhook endpoint URL |
| `hermes.webhookSecret` | string | — | HMAC secret for Hermes payloads |
| `hermes.routeName` | string | `"linear"` | Hermes webhook route name |
| `hermes.timeoutMs` | number | `15000` | Request timeout in ms |

## Credits

Infrastructure borrowed from:
Expand Down
198 changes: 198 additions & 0 deletions docs/hermes-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
---
name: linear-workflow
description: Manage Linear issues — view, update status, search, and post comments. Used when working on Linear tasks triggered via webhook.
version: 1.0.0
author: openclaw-linear-light
license: MIT
metadata:
hermes:
tags: [linear, project-management, workflow, issue-tracking]
related_skills: []
platforms: [macos, linux]
requires_toolsets: [terminal, file]
---

# Linear Workflow Skill

You are working on a Linear issue triggered by a webhook. This skill provides the tools and context you need.

## Environment

The Linear API token is available as:

```bash
LINEAR_API_TOKEN=$(grep "^LINEAR_API_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2-)
```

If not found there, check:

```bash
# Fallback: read from OpenClaw plugin config
LINEAR_API_TOKEN=$(python3 -c "
import json, os
cfg_path = os.path.expanduser('~/.openclaw/openclaw.json')
with open(cfg_path) as f:
cfg = json.load(f)
plugins = cfg.get('plugins', {})
entries = plugins.get('entries', plugins.get('linear-light', {}))
ll = entries.get('linear-light', entries) if isinstance(entries, dict) else {}
c = ll.get('config', {})
token = c.get('accessToken', '')
if not token:
# Try reading from Cyrus config
cyrus_path = os.path.expanduser('~/.cyrus/config.json')
if os.path.exists(cyrus_path):
with open(cyrus_path) as f2:
cyrus = json.load(f2)
token = cyrus.get('tokens', {}).get('linear', {}).get('access_token', '')
print(token)
")
```

The Linear API endpoint is `https://api.linear.app/graphql`.

## Core API Functions

### GraphQL Request Helper

```bash
linear_gql() {
local query="$1"
local vars="${2:-{}}"
curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $LINEAR_API_TOKEN" \
-d "{\"query\": $(echo "$query" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'), \"variables\": $vars}"
}
```

### Get Issue Details

```bash
linear_gql 'query($id: ID!) {
issue(id: $id) {
id identifier title description state { name } url
assignee { name } project { id name }
comments { nodes { body user { name } createdAt } }
labels { nodes { name } }
}
}' "{\"id\": \"$ISSUE_ID\"}"
```

### Update Issue Status

```bash
# First, find the state ID for the target status name
linear_gql 'query($teamId: String!, $name: String!) {
workflowStates(filter: { name: { eq: $name }, team: { id: { eq: $teamId } } }) {
nodes { id name }
}
}' "{\"teamId\": \"$TEAM_ID\", \"name\": \"$STATUS_NAME\"}"

# Then update (use the state ID from above)
linear_gql 'mutation($id: ID!, $stateId: String!) {
issueUpdate(input: { id: $id, stateId: $stateId }) {
issue { id state { name } }
}
}' "{\"id\": \"$ISSUE_ID\", \"stateId\": \"$STATE_ID\"}"
```

### Search Issues

```bash
linear_gql 'query($query: String!, $first: Int) {
issueSearch(query: $query, first: $first) {
nodes { id identifier title state { name } url }
}
}' "{\"query\": \"$SEARCH_QUERY\", \"first\": 20}"
```

### Create Comment (Reply to Issue)

**This is how you reply to Linear issues. Use this after completing your work.**

```bash
linear_gql 'mutation($issueId: ID!, $body: String!) {
commentCreate(input: { issueId: $issueId, body: $body }) {
success
comment { id }
}
}' "{\"issueId\": \"$ISSUE_ID\", \"body\": $(python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' <<< "$YOUR_REPLY")}"
```

### Emit Activity (typing indicator / initial response)

```bash
# Only if AGENT_SESSION_ID is available in the webhook payload
linear_gql 'mutation($agentSessionId: ID!, $body: String!) {
agentSessionActivityCreate(input: {
agentSessionId: $agentSessionId,
type: response,
body: $body
}) {
success
}
}' "{\"agentSessionId\": \"$AGENT_SESSION_ID\", \"body\": \"Working on $ISSUE_IDENTIFIER...\"}"
```

## Workflow

When you receive a Linear webhook trigger, the payload contains:

- `_linear_issue_id` — Linear issue UUID (use for API calls)
- `_linear_identifier` — Issue identifier (e.g. "ENG-42")
- `_linear_title` — Issue title
- `_linear_url` — Issue URL
- `prompt` — The full formatted prompt with issue description, user comments, and project context

### Step-by-step

1. **Read the prompt** — It contains the issue description, user comments, and project context
2. **Read project files** — The prompt tells you where project files are (e.g. `~/clawd/projects/<project-name>/`)
- Read `AGENTS.md`, `Context.md`, `README.md` for project context
3. **Do the work** — Implement, investigate, fix, or whatever the issue requires
4. **Update project files** — Update `Context.md` with your findings and progress
5. **Reply on Linear** — Post a comment summarizing what you did using `commentCreate`
6. **Update status** (optional) — If the work is complete, update the issue status using `issueUpdate`

### Project Memory

Project files are stored at `~/clawd/projects/<project-name>-<hash>/`. The prompt includes the exact path.

When you make progress, update these files:

- `Context.md` — Current state, findings, architecture decisions
- `README.md` — Progress and next steps (if significant)

After updating, commit:

```bash
cd ~/clawd/projects/<project-name>-<hash>
git add -A && git commit -m "update: <brief summary>"
```

### Replying to Linear

When posting a comment, format it clearly:

```
**Summary:** Brief description of what was done

**Details:**
- Point 1
- Point 2

**Next steps:**
- [ ] Remaining item 1
- [ ] Remaining item 2
```

Keep replies focused and actionable. The user will see this in Linear.

## Important Rules

1. **Always reply on Linear** after completing work — use `commentCreate` to post your response
2. **Don't modify issue status** unless the user explicitly asks you to (e.g. mark as Done)
3. **Read project files first** before starting work — they contain context from previous sessions
4. **Update project files** after making progress — this is how continuity works across sessions
5. **Use the exact issue ID** from the webhook payload (`_linear_issue_id`) for all API calls
7 changes: 7 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
setCompletionLoopLogger,
} from "./src/completion-loop.js"
import { validateConfig } from "./src/config-validation.js"
import { validateHermesConfig } from "./src/hermes-adapter.js"
import { handleOAuthCallback, handleOAuthInit } from "./src/oauth-handler.js"
import { getLinearApi, setLinearApi, setLinearRuntime } from "./src/runtime.js"
import { dispatchCompletionPrompt, handleWebhook, setFallbackDispatchContext } from "./src/webhook-handler.js"
Expand Down Expand Up @@ -165,6 +166,12 @@
// Register as a first-class channel — this is what makes deliver work
api.registerChannel({ plugin: linearPlugin as ChannelPlugin })

// Log Hermes mode status
const hermesValidation = validateHermesConfig(config || {})
if (hermesValidation.hermesConfig) {
api.logger.info(`Linear Light: Hermes mode enabled, forwarding to ${hermesValidation.hermesConfig.webhookUrl}`)
}

// Register Linear operation tools
for (const tool of createLinearTools(api)) {
api.registerTool(tool)
Expand Down Expand Up @@ -222,7 +229,7 @@
},
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default", configured: true }) as any,

Check warning on line 232 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
defaultAccountId: () => "default",
},
outbound: {
Expand All @@ -232,7 +239,7 @@
// We need to resolve it to an issue UUID and post a comment
const linearApi = getLinearApi()
if (!linearApi) {
return { channel: "linear", messageId: "", ok: false, error: "no access token" } as any

Check warning on line 242 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
}

try {
Expand All @@ -241,7 +248,7 @@
// For now, use the identifier to find the issue
const issueId = await resolveIssueId(linearApi, to)
if (!issueId) {
return { channel: "linear", messageId: "", ok: false, error: `could not resolve issue: ${to}` } as any

Check warning on line 251 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
}

const commentId = await linearApi.createComment(issueId, text)
Expand All @@ -256,14 +263,14 @@
}
}

return { channel: "linear", messageId: commentId || "", ok: true } as any

Check warning on line 266 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
} catch (err) {
return {
channel: "linear",
messageId: "",
ok: false,
error: err instanceof Error ? err.message : String(err),
} as any

Check warning on line 273 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/suspicious/noExplicitAny

Unexpected any. Specify a different type.
}
},
},
Expand Down Expand Up @@ -307,7 +314,7 @@
const tokenInfo = resolveLinearToken(config)
if (!tokenInfo.accessToken) return []

const linearApi = getLinearApi()!

Check warning on line 317 in index.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return [
{
Expand Down
32 changes: 32 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,38 @@
"type": "string",
"description": "Prompt template sent to agent on each loop tick. Supports {identifier}, {state}, {iteration} placeholders.",
"default": "[Completion Check] The issue {identifier} is still in \"{state}\" state. Please continue working on it. If you believe the work is complete, use the linear_update_status tool to mark it as Done."
},
"dispatchMode": {
"type": "string",
"description": "Agent dispatch mode: 'openclaw' (default, uses internal agent) or 'hermes' (forwards to Hermes Agent webhook)",
"default": "openclaw",
"enum": ["openclaw", "hermes"]
},
"hermes": {
"type": "object",
"description": "Hermes Agent configuration (required when dispatchMode is 'hermes')",
"properties": {
"webhookUrl": {
"type": "string",
"description": "Hermes webhook endpoint URL (e.g. http://localhost:8644/webhooks/linear)"
},
"webhookSecret": {
"type": "string",
"description": "HMAC secret for signing payloads sent to Hermes",
"sensitive": true
},
"routeName": {
"type": "string",
"description": "Hermes webhook route name (default: 'linear')",
"default": "linear"
},
"timeoutMs": {
"type": "number",
"description": "Timeout for Hermes webhook requests in milliseconds (default: 15000)",
"default": 15000
}
},
"required": ["webhookUrl", "webhookSecret"]
}
}
}
Expand Down
Loading
Loading