cx (Claude Extender) is a personal agent management system for Claude Code. It lets you create autonomous agents that run on schedules, watch for conditions, or maintain persistent sessions — all managed through markdown files in your cx directory.
- Installation & Setup
- Quickstart: Your First Agent
- CLI Reference
- Writing Watcher Scripts
- Memory & Compaction
- Configuration Reference
- Notifications
- MCP Tool Servers
- Telegram Bot
- Issue Agent (Linear Agent)
- Troubleshooting
- Node.js 20+ (for cx daemon and watcher scripts)
- Claude Code CLI installed and authenticated (
claudecommand available) - Anthropic API key with sufficient credits
- Git (optional, for version control)
# Install from source
git clone https://github.com/wbnns/cx.git && cd cx
npm install && npm run build && sudo npm link
# Verify installation
cx --versioncx needs to know your cx directory. Run init from inside your cx directory:
cd ~/my-project
cx initThis creates the directory structure and a config file at ~/.config/cx/config.yaml:
cx/agents/
cx/watchers/
cx/memory/
cx/runs/
cx/_templates/
cx/.trash/
The generated config file:
# ~/.config/cx/config.yaml
cx_path: ~/my-project
cx_folder: cx
timezone: America/Los_Angeles
daemon:
tick_interval_seconds: 30
log_file: ~/.config/cx/daemon.log
notifications:
telegram:
default_chat_id: "YOUR_CHAT_ID"
cost_limits:
daily_usd: 25
monthly_budget_usd: 100
alert_thresholds: [5, 10, 25]
compaction:
default_model: haikuSecrets live outside the cx directory so they're never synced or committed to Git:
# Global secrets (available to all agents)
cx secrets set global ANTHROPIC_API_KEY sk-ant-...
cx secrets set global TELEGRAM_BOT_TOKEN 123456:ABC...
# Agent-specific secrets
cx secrets set email GMAIL_USER you@gmail.com
cx secrets set email GMAIL_APP_PASSWORD xxxx-xxxx-xxxx
# List configured secret groups
cx secrets list
# global 2 keys
# email 2 keysSecrets are stored in ~/.config/cx/secrets/<group>.env.
cx daemon start
# cx daemon started (PID 12345)
# Check status
cx daemon status
# View daemon logs
cx daemon logs --followThe daemon must be running for scheduled, watcher, and persistent agents to execute automatically. Manual triggers via cx start also require the daemon.
MCP (Model Context Protocol) servers give your agents custom tools — Gmail access, calendar lookups, database queries, or anything else you can code. Here's how to set one up:
Create the tools directory:
mkdir -p cx/tools
cd cx/tools
npm init -y
# Set module type for ESM imports
npm pkg set type=module
npm install @modelcontextprotocol/sdkWrite an MCP server (e.g., cx/tools/my-mcp-server.js):
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'my-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'my_tool',
description: 'Does something useful',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'my_tool') {
const result = await doSomething(request.params.arguments.query);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);Create a config JSON (e.g., cx/tools/my-mcp-config.json):
{
"mcpServers": {
"myserver": {
"command": "node",
"args": ["/absolute/path/to/cx/tools/my-mcp-server.js"]
}
}
}Wire it into your agent's frontmatter:
mcp_config: /absolute/path/to/cx/tools/my-mcp-config.json
tools:
- mcp__myserver__my_toolThe tool naming convention is mcp__<server>__<tool> — where <server> matches the key in the config JSON and <tool> matches the name from ListTools.
Let's create a simple scheduled agent that runs once a day.
cx create surf-report --mode scheduledThis generates a markdown file with default frontmatter. Open it to fill in instructions:
cx edit surf-reportHere's what a complete agent file looks like:
---
type: agent
status: active
categories: [health, personal]
execution:
mode: scheduled
schedule:
type: cron
expression: "30 6 * * *"
timezone: America/Los_Angeles
tools: [web-search, web-fetch]
notifications:
- channel: telegram
resource_limits:
max_tokens: 10000
max_duration_seconds: 120
max_cost_usd: 0.15
memory:
enabled: true
max_current_tokens: 4000
env_ref: global
---
# Surf Report Agent
Check surf conditions for your local beaches.
Include wave height, period, wind, tides, and a
recommendation. Keep it under 200 words.cx start surf-reportOutput:
Starting surf-report (scheduled, manual trigger)...
Assembling context: instructions + memory
Spawning Claude Code...
Run completed in 47s
Tokens: 4,200 | Cost: $0.06
Run log: cx/runs/2026-02-12/surf-report-143500.md
Memory updated: cx/memory/surf-report/current.md
Check your cx directory. You'll see:
cx/agents/surf-report.md— Your agent file with updatedlast_runandtotal_runsfrontmattercx/runs/2026-02-12/surf-report-143500.md— The run log with output, token usage, and timingcx/memory/surf-report/current.md— The agent's memory, now containing its first run result
The daemon handles the rest. Tomorrow at 6:30 AM, cx will automatically run your surf report. Check on things anytime:
cx status
cx logs surf-report
cx costsHere's a production agent that uses MCP tools, notifications, and memory. It checks for new emails every morning, summarizes anything important, and archives the rest.
---
name: gmail-watcher
type: agent
status: active
execution:
mode: scheduled
schedule:
expression: "0 6 * * *"
type: cron
timezone: Atlantic/Azores
tools:
- mcp__gmail__list_unread
- mcp__gmail__list_inbox
- mcp__gmail__read_email
- mcp__gmail__archive
- mcp__gmail__mark_read
- mcp__gmail__flag
- mcp__gmail__trash
notifications:
- channel: telegram
events: [completion, failure]
memory:
enabled: true
env_ref: email
mcp_config: /home/deploy/nova/cx/tools/gmail-mcp-config.json
---
# Gmail Watcher
You are an email assistant. Every morning:
1. List unread emails
2. Read each one and decide its importance
3. Summarize anything that needs attention
4. Archive newsletters and notifications
5. Flag anything that needs a reply
Report your findings via the run output (sent to Telegram).This agent uses a Gmail MCP server (cx/tools/gmail-mcp-server.js) that connects to Gmail via IMAP and exposes tools for listing, reading, archiving, and flagging emails. The env_ref: email loads Gmail credentials from the email secret group. Notifications go to Telegram on completion or failure.
List all agents with status and key metadata.
cx list
# Filter by mode, category, or status
cx list --mode watcher
cx list --category personal
cx list --status pausedCreate a new agent from a template.
cx create <name> [--mode scheduled|watcher|persistent]
# Examples
cx create permit-tracker --mode watcher
cx create tax-prep --mode scheduled --category work
cx create research-task --mode persistentThe --mode flag determines which template and frontmatter fields are generated. Default mode is scheduled. Use --category to assign comma-separated categories.
Open an agent's markdown file in your editor.
cx edit surf-reportAfter saving, the daemon detects the file change and applies new config on the next tick. You can also edit agent files directly with any text editor.
Manually trigger an agent run.
cx start surf-report
# With verbose output (shows Claude's work in real-time)
cx start surf-report --verboseStop a running agent (primarily for persistent agents).
cx stop trade-monitorTemporarily suspend or resume an agent.
cx pause email-watcher
cx resume email-watcherPaused agents are skipped by the daemon but retain all configuration and memory.
Delete an agent with options for what to remove.
cx delete old-experimentYou'll see a menu:
Delete old-experiment?
[d] Delete agent file only (move to .trash/)
[a] Delete agent + all data (memory, logs)
[c] Cancel
Use -f to skip confirmation: cx delete old-experiment -f
Deleted agent files are moved to cx/.trash/ so you can restore them later.
View run logs for an agent.
cx logs surf-report # Last 5 runs (summaries)
cx logs surf-report --last 10 # Last 10 runs
cx logs surf-report --last 1 --output # Full output of last run
cx logs surf-report --follow # Watch for new logs in real-timeView an agent's current memory or archives.
cx memory surf-report # Current memory
cx memory surf-report --archive # List archive filesTrigger memory compaction.
cx compact surf-report # Compact one agent
cx compact surf-report --dry-run # Preview without executing
cx compact surf-report --all # Compact all agents over thresholdQuick overview of all agents and costs.
cx statusShows agent counts by status and mode, plus any agents in a failed state.
View cost breakdown.
cx costs # All-time costs by agent
cx costs --period 2026-02 # Filter by month
cx costs --by mode # Group by mode
cx costs --by category # Group by categoryManage the background daemon.
cx daemon start # Start daemon (background)
cx daemon stop # Stop daemon gracefully
cx daemon restart # Restart daemon
cx daemon status # Show status
cx daemon logs # View daemon logs
cx daemon logs --follow # Follow daemon logsManage secret groups.
cx secrets set <group> <KEY> <value>
cx secrets get <group> <KEY>
cx secrets list # List all groups
cx secrets list <group> # List keys in a group (values masked)
cx secrets delete <group> <KEY> # Delete a key
cx secrets delete <group> # Delete entire groupInstall dependencies for watcher scripts.
cx install-depsScans cx/watchers/ for *.package.json and *.requirements.txt files and runs npm install or pip install for each.
Test a watcher agent's check script without triggering a Claude run.
cx test-watcher email-watcherRuns the watcher script, displays the result, evaluates the trigger condition, and shows what would happen.
Watcher scripts are lightweight programs that run frequently without Claude API calls. They check a condition and return whether to trigger a full Claude run.
A watcher script exports a single async check function:
// watchers/my-watcher.js
module.exports = async function check(config) {
// config.agent_name → agent name
// config.last_check → ISO timestamp of last check
// Do your cheap check here...
return {
triggered: true, // or false
context: { // data passed to Claude when triggered
key: "value"
}
};
};The context object is serialized as JSON and included in the Claude run's input when pass_context is true in the agent's frontmatter. Put useful data here so the agent doesn't have to re-fetch it.
// watchers/email-watcher.js
const Imap = require("imap");
module.exports = async function check(config) {
const emails = await getNewEmails(
config.env.GMAIL_USER,
config.env.GMAIL_APP_PASSWORD,
new Date(config.last_check)
);
return {
triggered: emails.length > 0,
context: {
new_count: emails.length,
subjects: emails.map(e => e.subject),
senders: emails.map(e => e.from),
preview: emails.slice(0, 10).map(e => ({
from: e.from,
subject: e.subject,
snippet: e.text?.substring(0, 200)
}))
}
};
};# watchers/permit-tracker.py
import requests, hashlib, json
def check(config):
url = "https://example.com/permits"
resp = requests.get(url)
current_hash = hashlib.md5(resp.text.encode()).hexdigest()
prev = config.get("prev_hash", "")
changed = current_hash != prev
return {
"triggered": changed,
"context": {
"page_content": resp.text[:5000],
"changed": changed
}
}If your watcher needs npm or pip packages, add dependency files alongside it:
watchers/
email-watcher.js
email-watcher.package.json ← {"dependencies":{"imap":"^0.8"}}
permit-tracker.py
permit-tracker.requirements.txt ← requests>=2.28
Install them all at once:
cx install-depscx test-watcher email-watcherThis runs the check script, displays the returned triggered and context values, evaluates the trigger_condition expression against the context, and tells you whether a Claude run would have been triggered.
A common pattern is to pair a lightweight watcher script with a full MCP server. The watcher does a cheap check to decide whether to trigger, while the MCP server provides rich tools for the Claude run itself.
Example: Gmail
The gmail-watcher agent uses two scripts:
- Watcher (
cx/watchers/gmail-watcher.js) — A cheap IMAP check that looks for new unseen email UIDs. No Claude API calls. Runs every few minutes via the daemon. - MCP server (
cx/tools/gmail-mcp-server.js) — Full IMAP client exposing tools likelist_unread,read_email,archive, andmark_read. Only starts when the watcher triggers a Claude run.
The watcher script tracks seen UIDs in a local cache file so it only triggers when genuinely new emails arrive:
// watchers/gmail-watcher.js (simplified)
module.exports.check = async function check() {
const currentUids = await getUnseenUids(); // cheap IMAP SEARCH
const trackedUids = loadFromCache();
const newUids = currentUids.filter(uid => !trackedUids.has(uid));
if (newUids.length === 0) return { triggered: false };
saveToCache(currentUids);
return { triggered: true, context: { count: newUids.length } };
};When triggered, the agent starts the Gmail MCP server (via mcp_config) and has full access to read, archive, and flag emails using the mcp__gmail__* tools.
This split keeps costs low — the watcher runs without Claude API calls, and the MCP server only spins up when there's actual work to do.
Each agent has a memory folder at cx/memory/<agent-name>/. The current.md file is injected into every Claude run as context, giving the agent awareness of what it's done before.
After each run, the agent's output is appended to current.md. Over time, this file grows. Compaction summarizes older entries and moves them to archive files, keeping current.md focused and costs manageable.
The "Persistent Notes" section at the top of current.md is special — it survives compaction. Use it for long-term facts:
## Persistent Notes
- Best bodyboarding spot: Praia do Norte
- User prefers morning sessions before 9am
- NE wind = good conditions at NorteAgents can add to this section via their output. You can also edit it directly to seed an agent with knowledge.
cx compact surf-report # Compact one agent
cx compact surf-report --all # All agents over threshold
cx compact surf-report --dry-run # Preview without executingArchives are regular markdown files in cx/memory/<agent>/archive/. View them in any text editor or via CLI:
cx memory surf-report --archiveConfigured per-agent via the memory.archive_access field:
on_demand— Agent can request an archive mid-run (costs an extra API call)always_recent— Most recent archive always included in contextnever— Archives are for your reference only; keeps costs minimal
All configuration lives in the agent's YAML frontmatter. Here is every field:
| Field | Type | Default | Description |
|---|---|---|---|
name |
String | filename | Human-readable identifier for the agent |
type |
String | agent |
Always "agent" |
status |
Enum | active |
active | paused | stopped | failed |
categories |
String[] | [] |
Tags for organization and cost grouping |
tools |
String[] | [] |
Tools available to the agent (use mcp__<server>__<tool> for MCP tools) |
model |
String | config default | Model override (e.g., sonnet, opus, haiku) |
env_ref |
String | global |
Secret group name to load |
mcp_config |
String | — | Absolute path to MCP server config JSON file |
| Field | Mode | Description |
|---|---|---|
execution.mode |
All | scheduled | watcher | persistent |
execution.schedule.type |
Scheduled | cron | once | manual |
execution.schedule.expression |
Scheduled | Cron expression (e.g., "30 6 * * *") |
execution.schedule.timezone |
Scheduled | IANA timezone (e.g., America/Los_Angeles) |
execution.watcher.script |
Watcher | Script path relative to cx/watchers/ |
execution.watcher.poll_interval_seconds |
Watcher | How often to run the check script |
execution.watcher.trigger_condition |
Watcher | Expression evaluated against context |
execution.watcher.cooldown_seconds |
Watcher | Minimum time between triggers |
execution.watcher.pass_context |
Watcher | Include watcher context in Claude run |
execution.persistent.heartbeat_interval_seconds |
Persistent | Daemon health check frequency |
execution.persistent.checkpoint_interval_minutes |
Persistent | How often agent writes checkpoint to memory |
execution.persistent.max_session_duration_hours |
Persistent | Max session length before restart |
execution.persistent.restart_policy |
Persistent | always | on_failure | never |
execution.persistent.restart_delay_seconds |
Persistent | Delay before restarting |
| Field | Description |
|---|---|
resource_limits.max_tokens |
Max tokens per run |
resource_limits.max_duration_seconds |
Max wall-clock time per run |
resource_limits.max_cost_usd |
Max cost per run |
resource_limits.max_tokens_per_hour |
Hourly token budget (persistent) |
resource_limits.max_cost_per_day_usd |
Daily cost ceiling (persistent) |
| Field | Description |
|---|---|
memory.enabled |
Enable/disable memory for this agent |
memory.compaction_policy |
summarize | truncate |
memory.max_current_tokens |
Compaction trigger threshold for current.md |
memory.archive_access |
on_demand | always_recent | never |
notifications:
- channel: telegram
events: [completion, failure, trigger, budget_warning]The mcp_config frontmatter field points to a JSON file that defines which MCP servers to start for a run:
{
"mcpServers": {
"server_name": {
"command": "node",
"args": ["/absolute/path/to/mcp-server.js"]
}
}
}Multiple servers can be defined in one config file. Each server key becomes the middle segment of the mcp__<server>__<tool> naming convention.
| Field | Type | Description |
|---|---|---|
cx_path |
String | Path to the project containing the cx/ directory |
claude_path |
String | Path to the Claude Code CLI binary (default: claude) |
default_model |
String | Default model for agent runs (e.g., sonnet, opus) |
default_permission_mode |
String | Claude Code permission mode for agent runs (e.g., dangerouslySkipPermissions for autonomous agents) |
cx_folder |
String | Name of the cx subdirectory (default: cx) |
timezone |
String | Default IANA timezone |
daemon.tick_interval_seconds |
Number | How often the daemon checks for work |
daemon.log_file |
String | Path to daemon log file |
notifications.telegram.bot_token |
String | Telegram bot API token |
notifications.telegram.default_chat_id |
String | Default Telegram chat ID for notifications |
cost_limits.daily_usd |
Number | Daily cost ceiling across all agents |
cost_limits.monthly_budget_usd |
Number | Monthly cost budget |
compaction.default_model |
String | Model used for memory compaction (default: haiku) |
cx sends notifications via Telegram when agent events occur. Configure notifications per-agent in frontmatter:
notifications:
- channel: telegram
events: [completion, failure]Available events: completion, failure, trigger, budget_warning.
Global Telegram credentials are configured in ~/.config/cx/config.yaml:
notifications:
telegram:
bot_token: "YOUR_BOT_TOKEN"
default_chat_id: "YOUR_CHAT_ID"MCP (Model Context Protocol) servers are Node.js (or Python) programs that expose custom tools to Claude Code agents. They communicate over stdio — Claude Code starts the server, calls tools during a run, and shuts it down when finished.
cx/tools/
gmail-mcp-server.js # MCP server implementation
gmail-mcp-config.json # Config JSON for the server
calendar-mcp-server.js
calendar-mcp-config.json
telegram-bot.js # Other supporting scripts
transcribe.py
package.json # Shared dependencies
The cx/tools/package.json manages shared dependencies for all MCP servers:
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0",
"imap": "^0.8",
"mailparser": "^3.7",
"node-ical": "^0.25.1"
}
}Install with npm install from the cx/tools/ directory.
Every MCP server implements two handlers:
ListTools— Returns an array of tool definitions (name, description, input schema)CallTool— Executes a tool by name and returns the result
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'my-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'my_tool',
description: 'Does something useful',
inputSchema: {
type: 'object',
properties: {
param: { type: 'string', description: 'Input parameter' },
},
required: ['param'],
},
},
],
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'my_tool') {
const result = await doWork(args.param);
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
}
throw new Error(`Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);Each MCP server needs a config JSON that tells Claude Code how to start it:
{
"mcpServers": {
"gmail": {
"command": "node",
"args": ["/home/deploy/nova/cx/tools/gmail-mcp-server.js"]
}
}
}The server key (gmail) becomes the middle segment in the tool naming convention: mcp__gmail__list_unread.
Add two fields to the agent's frontmatter:
mcp_config: /home/deploy/nova/cx/tools/gmail-mcp-config.json
tools:
- mcp__gmail__list_unread
- mcp__gmail__read_email
- mcp__gmail__archive
- mcp__gmail__mark_readThe agent can only call tools listed in its tools array — this acts as an allowlist even if the MCP server exposes more tools.
A production Gmail MCP server that connects via IMAP and exposes email management tools:
// cx/tools/gmail-mcp-server.js (abbreviated)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import Imap from 'imap';
import { simpleParser } from 'mailparser';
const server = new Server(
{ name: 'gmail', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{ name: 'list_unread', description: 'List unread emails', inputSchema: { type: 'object', properties: { limit: { type: 'number' } } } },
{ name: 'read_email', description: 'Read a specific email by UID', inputSchema: { type: 'object', properties: { uid: { type: 'string' } }, required: ['uid'] } },
{ name: 'archive', description: 'Archive an email by UID', inputSchema: { type: 'object', properties: { uid: { type: 'string' } }, required: ['uid'] } },
{ name: 'mark_read', description: 'Mark an email as read', inputSchema: { type: 'object', properties: { uid: { type: 'string' } }, required: ['uid'] } },
// ... more tools
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'list_unread': return await listUnread(args.limit);
case 'read_email': return await readEmail(args.uid);
case 'archive': return await archiveEmail(args.uid);
case 'mark_read': return await markRead(args.uid);
default: throw new Error(`Unknown tool: ${name}`);
}
});
const transport = new StdioServerTransport();
await server.connect(transport);Environment variables (GMAIL_USER, GMAIL_APP_PASSWORD) are injected via the agent's env_ref: email secret group.
A simpler single-tool server that fetches today's calendar events from ICS URLs:
// cx/tools/calendar-mcp-server.js (abbreviated)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import ical from 'node-ical';
const server = new Server(
{ name: 'calendar', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_today',
description: "List today's calendar events",
inputSchema: { type: 'object', properties: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'list_today') {
const events = await fetchTodaysEvents(); // Fetches from GOOGLE_CALENDAR_ICS_URLS env var
return { content: [{ type: 'text', text: JSON.stringify(events) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);Agent frontmatter:
mcp_config: /home/deploy/nova/cx/tools/calendar-mcp-config.json
tools:
- mcp__calendar__list_todaycx includes a Telegram bot (cx/tools/telegram-bot.js) that lets you control your agents via natural language messages from your phone.
The bot uses long-polling to receive messages and interprets them via Claude Haiku. You can send natural language like "check my emails" or "what's the gmail watcher status?" and the bot translates that into cx commands.
Capabilities:
- Status & listing — "status", "list agents", "what's running?"
- Logs — "show gmail-watcher logs", "last run output"
- Memory — "what does gmail-watcher remember?"
- Control — "start gmail-watcher", "pause calendar-checker", "resume all"
- Persistent notes — "remember that I'm on vacation until Friday"
- Voice messages — Transcribes voice notes and processes them as commands
- Research mode — "research best practices for X" (triggers a longer Claude run)
The bot reads its configuration from ~/.config/cx/config.yaml:
notifications:
telegram:
bot_token: "YOUR_BOT_TOKEN"
default_chat_id: "YOUR_CHAT_ID"- Create a bot via @BotFather on Telegram
- Copy the bot token to
config.yaml - Send a message to your bot, then use the Telegram API to find your chat ID
- Set
default_chat_idinconfig.yaml
# Direct
node cx/tools/telegram-bot.js
# Via pm2 (recommended for production)
pm2 start cx/tools/telegram-bot.js --name cx-telegram-bot
# Via systemd
# Create a service file pointing to the scriptThe bot runs as a separate long-lived process alongside the cx daemon.
The Issue Agent is a standalone service that turns issue tracker links into completed PRs. Send a Linear issue URL to the Telegram bot, and the service handles everything: worktree creation, implementation, PR creation, review feedback remediation, and CI fixes.
User sends issue URL to Telegram bot
│
▼
telegram-bot.js detects the URL pattern
│ POST http://localhost:7483/trigger
▼
linear-agent.mjs (systemd service)
│
├─ Create git worktree from main
├─ Fetch issue details via tracker API
├─ Symlink pre-installed node_modules
├─ Spawn Claude (opus, 45 min timeout)
│ └─ Read issue, implement changes, push, open PR
├─ Notify via Telegram: "PR opened: <url>"
│
├─ Feedback loop (every 10 min):
│ ├─ Fetch review + issue comments via GitHub API
│ ├─ Filter out bot comments (vercel, github-actions)
│ ├─ If new actionable comments:
│ │ ├─ Spawn Claude to remediate (15 min timeout)
│ │ └─ If greptile commented → post "@greptileai review"
│ └─ If no new comments → exit loop
│
├─ CI check (10 min after feedback settles):
│ ├─ Fetch PR check status via gh
│ ├─ If failures related to PR → Claude fixes, push
│ │ └─ Post "@greptileai review" → re-enter feedback loop
│ └─ If CI green or failures unrelated → done
│
└─ Notify: "complete — all feedback addressed"
The service requires:
- A git repository with worktree support (the target codebase)
- GitHub CLI (
gh) authenticated for PR creation and API calls - An issue tracker API key (Linear API key, or equivalent for other trackers)
- The Telegram bot already running (to receive triggers and send notifications)
Files:
| File | Purpose |
|---|---|
cx/services/linear-agent.mjs |
The service itself |
/etc/systemd/system/cx-linear-agent.service |
systemd unit |
cx/tools/telegram-bot.js |
Modified to detect issue URLs |
Start the service:
sudo systemctl enable cx-linear-agent
sudo systemctl start cx-linear-agent
# Restart the Telegram bot to pick up the URL detection hook
sudo systemctl restart cx-telegram-botKey constants at the top of linear-agent.mjs:
| Constant | Default | Description |
|---|---|---|
ZORA_REPO |
/home/deploy/zora |
Path to the main git repository |
WORKTREE_ROOT |
/home/deploy/zora-worktrees |
Where worktrees are created |
PORT |
7483 |
HTTP port for trigger endpoint |
INITIAL_TIMEOUT_MS |
45 min | Max time for initial Claude session |
REMEDIATION_TIMEOUT_MS |
15 min | Max time for remediation sessions |
FEEDBACK_INTERVAL_MS |
10 min | How often to check for new comments |
CI_SETTLE_DELAY_MS |
10 min | Wait time before checking CI |
WORKTREE_MAX_AGE_MS |
7 days | Auto-cleanup threshold for old worktrees |
LINEAR_API_KEY |
— | API key for fetching issue details |
Send a Linear issue link to the Telegram bot:
https://linear.app/myorg/issue/PROJ-123
The bot responds with a confirmation and the service takes over. You'll receive Telegram notifications at each milestone.
Multiple issues can be processed concurrently — each gets its own worktree and Claude session. Duplicate triggers for the same issue are detected and rejected.
The service is designed around Linear but the pattern is generic. To use a different tracker:
- URL detection — Update the regex in
telegram-bot.jsto match your tracker's URL format (e.g.,github.com/.*/issues/\d+for GitHub Issues) - Issue fetching — Replace the
fetchLinearIssue()function with an API call to your tracker (GitHub REST API, Jira API, etc.) - Issue ID format — Adjust the
issueIdparsing and branch naming to match your tracker's conventions
For example, to use GitHub Issues instead of Linear:
// In telegram-bot.js — detect GitHub issue URLs
const ghMatch = text.match(
/https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/i
);
// In linear-agent.mjs — fetch via GitHub API
async function fetchGitHubIssue(repo, number) {
const result = execFileSync('gh', [
'api', `repos/${repo}/issues/${number}`,
], { encoding: 'utf-8' });
const issue = JSON.parse(result);
return `## #${issue.number}: ${issue.title}\n\n${issue.body}`;
}Each Claude session uses ~250-300MB of RAM. On machines with limited memory:
- Add swap — The service can trigger multiple concurrent Claude sessions plus
pnpmbuilds. A 4GB swap file prevents OOM kills. - Pre-install dependencies — The service symlinks
node_modulesfrom the main repo into each worktree to avoid expensivepnpm installruns. - Worktree cleanup — Old worktrees are automatically removed after 7 days. The cleanup runs on startup and every 6 hours.
# Service status
sudo systemctl status cx-linear-agent
# Live logs
sudo journalctl -u cx-linear-agent -f
# Health check
curl http://localhost:7483/health
# {"status":"ok","activeJobs":1}
# Check active Claude processes
ps aux | grep claude- Is the daemon running?
cx daemon status - Is the agent status
active? Check frontmatter. - Is the cron expression correct? Use crontab.guru to verify.
- Check timezone — is it set to the right IANA timezone?
- Check daemon logs:
cx daemon logs --follow
- Test the watcher script:
cx test-watcher <name> - Check
trigger_conditionmatches the context keys your script returns. - Is the watcher in cooldown? Check
cooldown_secondsand last trigger time. - Are dependencies installed?
cx install-deps
- Check resource limits — is
max_tokens_per_hourtoo low? - Check daemon logs for heartbeat failures.
- Increase
heartbeat_interval_secondsif the agent does heavy work between checks. - Check
max_session_duration_hours— is it expiring normally?
- Run
cx compact <name>to force compaction. - Lower
max_current_tokensto trigger compaction earlier. - Review agent instructions — is it writing too much to memory?
- Run
cx costs --period 2026-02 --by agentto identify expensive agents. - Consider moving scheduled agents to watcher mode if many runs find nothing actionable.
- Lower
resource_limits.max_cost_usdper run. - Use
compaction.default_model: haikuin config for cheaper compaction. - Check persistent agents — are they necessary, or would a watcher suffice?
- Verify the secret group exists:
cx secrets list - Check that
env_refin the agent frontmatter matches the group name. - Verify the specific key:
cx secrets get <group> <KEY>
cx is an independent open-source project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. Claude and Claude Code are trademarks of Anthropic, PBC.