{
  "id": "UGhTOwk0sz0gm8PU",
  "name": "Manage AI coding sessions from Matrix with YouTrack and GitLab",
  "tags": [],
  "nodes": [
    {
      "id": "7c31b18c",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        -352
      ],
      "parameters": {
        "color": 4,
        "width": 540,
        "height": 520,
        "content": "## Manage AI coding sessions from Matrix\nA chat-ops bridge between Matrix, Claude Code,\nYouTrack, and GitLab. Your team talks to an AI\ncoding assistant from a chat room \u2014 with issue\ntracking and CI/CD visibility built in.\n\n## How it works\n1. Polls a Matrix room every 30s for messages\n2. Routes `!commands` to the matching handler\n3. Forwards messages to Claude Code via SSH\n4. Posts Claude's response back to Matrix\n5. Syncs session state with YouTrack issues\n\n## Setup steps\n1. Import and edit the **Gateway Config** node\n2. Create n8n credentials: SSH + Matrix token\n3. Set `YOUTRACK_TOKEN` and `GITLAB_TOKEN`\n4. Create the SQLite database (see description)\n5. Activate the workflow"
      },
      "typeVersion": 1
    },
    {
      "id": "4ceda743",
      "name": "Sticky Note 4fa9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        176,
        224
      ],
      "parameters": {
        "width": 260,
        "height": 228,
        "content": "### 1. Configuration\nUser-configurable variables."
      },
      "typeVersion": 1
    },
    {
      "id": "277ed962",
      "name": "Sticky Note 7417",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        464,
        224
      ],
      "parameters": {
        "width": 960,
        "height": 228,
        "content": "### 2. Matrix polling\nPolls Matrix /sync every 30 seconds, extracts new messages, filters empty batches."
      },
      "typeVersion": 1
    },
    {
      "id": "874e52f1",
      "name": "Sticky Note 782c",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1472,
        224
      ],
      "parameters": {
        "width": 500,
        "height": 420,
        "content": "### 3. Command routing\nParses `!commands`, routes to handlers."
      },
      "typeVersion": 1
    },
    {
      "id": "8489758d",
      "name": "Sticky Note c9e3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2016,
        -304
      ],
      "parameters": {
        "width": 1576,
        "height": 374,
        "content": "### 4. Message handler\nChecks lock, reads session, resumes Claude via SSH, posts response to Matrix."
      },
      "typeVersion": 1
    },
    {
      "id": "2e04a109",
      "name": "Sticky Note 6e7b",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2016,
        128
      ],
      "parameters": {
        "width": 592,
        "height": 1248,
        "content": "### 5. Command handlers\n**!session** current, list, done, cancel, pause, resume\n**!issue** status, info, start, verify, done, comment\n**!pipeline** status, logs, retry\n**!system** status | **!help** reference"
      },
      "typeVersion": 1
    },
    {
      "id": "7c36c474",
      "name": "Sticky Note dbbd",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2656,
        496
      ],
      "parameters": {
        "width": 760,
        "height": 288,
        "content": "### 6. Response and session end\nPosts output to Matrix. Archives session to SQLite on done."
      },
      "typeVersion": 1
    },
    {
      "id": "gateway-config",
      "name": "Gateway Config",
      "type": "n8n-nodes-base.set",
      "position": [
        208,
        304
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "ssh-host",
              "name": "SSH_HOST",
              "type": "string",
              "value": "your-server-hostname"
            },
            {
              "id": "e402df7c",
              "name": "MATRIX_HOMESERVER",
              "type": "string",
              "value": "https://your-matrix-server.com"
            },
            {
              "id": "7a2638f9",
              "name": "MATRIX_ROOM_ID",
              "type": "string",
              "value": "!yourRoomId:your-matrix-server.com"
            },
            {
              "id": "f020d2f8",
              "name": "MATRIX_BOT_USER",
              "type": "string",
              "value": "@bot:your-matrix-server.com"
            },
            {
              "id": "dc840315",
              "name": "YOUTRACK_URL",
              "type": "string",
              "value": "https://your-youtrack-instance.com"
            },
            {
              "id": "0e88ef8d",
              "name": "GITLAB_URL",
              "type": "string",
              "value": "https://your-gitlab-instance.com"
            },
            {
              "id": "0ef605c8",
              "name": "CLAUDE_PROJECT_PATH",
              "type": "string",
              "value": "/home/user/your-project"
            },
            {
              "id": "e0482d36",
              "name": "CLAUDE_BINARY",
              "type": "string",
              "value": "/home/user/.local/bin/claude"
            },
            {
              "id": "690b2fc6",
              "name": "DB_PATH",
              "type": "string",
              "value": "/home/user/your-project/claude-context/gateway.db"
            },
            {
              "id": "e8bcc6ff",
              "name": "CONTEXT_DIR",
              "type": "string",
              "value": "/home/user/your-project/claude-context"
            },
            {
              "id": "393a34b8",
              "name": "ISSUE_PREFIX",
              "type": "string",
              "value": "PROJ"
            },
            {
              "id": "8d185b9c",
              "name": "COOLDOWN_TTL",
              "type": "string",
              "value": "30"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "poll-trigger",
      "name": "Poll Every 30s",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        512,
        304
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "seconds"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "get-sync-token",
      "name": "Get Sync Token",
      "type": "n8n-nodes-base.code",
      "position": [
        704,
        304
      ],
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst sinceToken = staticData.matrixSinceToken || '';\nreturn [{ json: { sinceToken, ...$('Gateway Config').first().json } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "poll-matrix",
      "name": "Poll Matrix Sync",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        912,
        304
      ],
      "parameters": {
        "url": "={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/sync?timeout=0&filter={\"room\":{\"rooms\":[\"{{ $json.MATRIX_ROOM_ID }}\"],\"timeline\":{\"limit\":10}}}{{ $json.sinceToken ? '&since=' + $json.sinceToken : '' }}",
        "options": {
          "timeout": 15000
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "extract-messages",
      "name": "Extract Messages",
      "type": "n8n-nodes-base.code",
      "position": [
        1104,
        304
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst syncData = $input.first().json;\nconst staticData = $getWorkflowStaticData('global');\n\n// Save sync token for next poll\nif (syncData.next_batch) staticData.matrixSinceToken = syncData.next_batch;\n\n// Extract messages from timeline\nconst roomId = config.MATRIX_ROOM_ID;\nconst rooms = syncData.rooms?.join || {};\nconst room = rooms[roomId] || syncData.rooms?.join?.[Object.keys(syncData.rooms?.join || {})[0]];\nconst events = room?.timeline?.events || [];\n\nconst lastTs = staticData.lastProcessedTimestamp || 0;\nconst botUser = config.MATRIX_BOT_USER;\n\nconst messages = events\n  .filter(e => e.type === 'm.room.message'\n    && e.sender !== botUser\n    && e.origin_server_ts > lastTs)\n  .map(e => ({\n    messageText: e.content?.body || '',\n    sender: e.sender,\n    timestamp: e.origin_server_ts\n  }));\n\nif (messages.length > 0) {\n  staticData.lastProcessedTimestamp = Math.max(...messages.map(m => m.timestamp));\n}\n\nreturn messages.length > 0\n  ? [{ json: { ...messages[0], ...config } }]\n  : [];"
      },
      "typeVersion": 2
    },
    {
      "id": "has-messages",
      "name": "Has Messages?",
      "type": "n8n-nodes-base.if",
      "position": [
        1312,
        304
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c8b1ae50",
              "operator": {
                "type": "string",
                "operation": "isNotEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.messageText }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "detect-command",
      "name": "Detect Command",
      "type": "n8n-nodes-base.code",
      "position": [
        1504,
        304
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst messageText = $input.first().json.messageText || '';\nconst trimmed = messageText.trim();\nconst lower = trimmed.toLowerCase();\nconst base64Message = Buffer.from(messageText).toString('base64');\n\n// Check for PREFIX: message format (e.g. PROJ-4: do something)\nconst prefixMatch = trimmed.match(/^([A-Z]+-\\d+):\\s*([\\s\\S]*)$/);\nif (prefixMatch) {\n  const prefix = prefixMatch[1];\n  const strippedText = prefixMatch[2].trim();\n  if (!strippedText) {\n    return [{ json: { ...config, command: 'empty', matrixBody: JSON.stringify({ msgtype: 'm.notice', body: 'Message body is empty. Usage: ' + prefix + ': <your message>' }) } }];\n  }\n  const base64Stripped = Buffer.from(strippedText).toString('base64');\n  return [{ json: { ...config, command: 'message', sub: '', args: [], messageText: strippedText, base64Message: base64Stripped, prefix } }];\n}\n\nif (!lower.startsWith('!')) {\n  if (!trimmed) return [{ json: { ...config, command: 'empty', matrixBody: JSON.stringify({ msgtype: 'm.notice', body: 'Message cannot be empty.' }) } }];\n  return [{ json: { ...config, command: 'message', sub: '', args: [], messageText, base64Message, prefix: '' } }];\n}\n\nconst lowerParts = lower.slice(1).split(/\\s+/);\nconst origParts = trimmed.slice(1).split(/\\s+/);\nconst command = lowerParts[0] || '';\nconst sub = lowerParts[1] || '';\nconst args = origParts.slice(2);\n\n// Legacy aliases\nconst resolved = { done: 'session', cancel: 'session', status: 'session' }[command] || command;\nconst resolvedSub = { done: 'done', cancel: 'cancel', status: 'current' }[command] || sub;\n\nreturn [{ json: { ...config, command: resolved, sub: resolvedSub, args, messageText, base64Message, prefix: '' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "command-router",
      "name": "Command Router",
      "type": "n8n-nodes-base.switch",
      "position": [
        1760,
        304
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "message",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "message"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "session",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "session"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "help",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "help"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "issue",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "issue"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "pipeline",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "pipeline"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "system",
              "conditions": {
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.command }}",
                    "rightValue": "system"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "check-lock",
      "name": "Check Lock",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2064,
        -112
      ],
      "parameters": {
        "command": "={{ 'GW=\"' + $json.CONTEXT_DIR + '\" && if [ -f $GW/gateway.lock ] && [ $(( $(date +%s) - $(stat -c %Y $GW/gateway.lock) )) -lt 600 ]; then echo LOCKED; else rm -f $GW/gateway.lock && echo FREE; fi' }}",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "is-locked",
      "name": "Is Locked?",
      "type": "n8n-nodes-base.if",
      "position": [
        2272,
        -112
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "02be178d",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.stdout.trim() }}",
              "rightValue": "LOCKED"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "post-busy",
      "name": "Post Busy Notice",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2608,
        -240
      ],
      "parameters": {
        "url": "={{ $('Gateway Config').first().json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $('Gateway Config').first().json.MATRIX_ROOM_ID }}/send/m.room.message/busy-{{ Date.now() }}",
        "method": "PUT",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ msgtype: 'm.notice', body: 'Claude is busy processing a previous message. Your message has been queued.' }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "read-session",
      "name": "Read Session & Acquire Lock",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2496,
        -112
      ],
      "parameters": {
        "command": "={{ 'DB=\"' + $('Gateway Config').first().json.DB_PATH + '\" && GW=\"' + $('Gateway Config').first().json.CONTEXT_DIR + '\" && echo locked > $GW/gateway.lock && sqlite3 $DB \"SELECT json_object(\\'sessionId\\',session_id,\\'issueId\\',issue_id,\\'issueTitle\\',issue_title) FROM sessions WHERE is_current=1 LIMIT 1;\" 2>/dev/null' }}",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "resume-claude",
      "name": "Resume Claude Session",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2720,
        -112
      ],
      "parameters": {
        "command": "=CLAUDE_BIN=\"{{ $(\"Gateway Config\").first().json.CLAUDE_BINARY }}\"\nPROJECT=\"{{ $(\"Gateway Config\").first().json.CLAUDE_PROJECT_PATH }}\"\nSESSION_RAW=\"{{ $(\"Read Session & Acquire Lock\").first().json.stdout || '' }}\"\nMSG_B64=\"{{ $(\"Detect Command\").first().json.base64Message }}\"\n\nSESSION_RAW=$(echo \"$SESSION_RAW\" | tr -d '\\n')\n[ -z \"$SESSION_RAW\" ] && echo \"NO_SESSION\" && exit 0\n\nSID=$(echo \"$SESSION_RAW\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('sessionId',''))\" 2>/dev/null)\n[ -z \"$SID\" ] && echo \"NO_SESSION\" && exit 0\n\nunset CLAUDECODE\ncd \"$PROJECT\"\ntimeout 300 \"$CLAUDE_BIN\" -r \"$SID\" -p \"$(printf '%s' \"$MSG_B64\" | base64 -d)\" --output-format json --dangerously-skip-permissions 2>&1",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "parse-response",
      "name": "Parse Claude Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2944,
        -112
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst stderr = $input.first().json.stderr || '';\nconst output = (stdout.trim() || stderr.trim());\n\nif (output === 'NO_SESSION') {\n  const body = 'No active Claude session. Start one via YouTrack or !issue start <id>.';\n  return [{ json: { matrixBody: JSON.stringify({ msgtype: 'm.notice', body }), ...config } }];\n}\n\nlet result;\ntry {\n  const parsed = JSON.parse(output);\n  result = parsed.result || output.substring(0, 4000);\n} catch(e) {\n  result = output.substring(0, 4000) || 'No response from Claude.';\n}\n\nconst body = result;\nconst matrixBody = JSON.stringify({ msgtype: 'm.text', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "post-response",
      "name": "Post Response to Matrix",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3152,
        -112
      ],
      "parameters": {
        "url": "={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $json.MATRIX_ROOM_ID }}/send/m.room.message/resp-{{ Date.now() }}",
        "method": "PUT",
        "options": {},
        "jsonBody": "={{ $json.matrixBody }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "release-lock",
      "name": "Release Lock",
      "type": "n8n-nodes-base.ssh",
      "position": [
        3376,
        -112
      ],
      "parameters": {
        "command": "={{ 'rm -f \"' + $('Gateway Config').first().json.CONTEXT_DIR + '/gateway.lock\"' }}",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "handle-session",
      "name": "Handle Session Command",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2064,
        272
      ],
      "parameters": {
        "command": "=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'current' }}\"\nRAW_ARG0=\"{{ $(\"Detect Command\").first().json.args[0] || '' }}\"\nDB=\"{{ $(\"Gateway Config\").first().json.DB_PATH }}\"\nGW=\"{{ $(\"Gateway Config\").first().json.CONTEXT_DIR }}\"\n\nARG0=$(echo \"$RAW_ARG0\" | tr '[:lower:]' '[:upper:]')\n\ncase \"$SUB\" in\n  current)\n    ROW=$(sqlite3 \"$DB\" \"SELECT json_object('issue_id',issue_id,'session_id',session_id,'started_at',started_at,'message_count',message_count,'paused',paused) FROM sessions WHERE is_current=1 LIMIT 1;\")\n    echo \"SESSION:${ROW:-NO_SESSION}\"\n    ;;\n  list)\n    sqlite3 \"$DB\" \"SELECT json_group_array(json_object('issue_id',issue_id,'started_at',started_at,'message_count',message_count,'paused',paused,'is_current',is_current)) FROM sessions ORDER BY is_current DESC;\" 2>/dev/null\n    ;;\n  done)\n    if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n    sqlite3 \"$DB\" \"SELECT json_object('session_id',session_id,'issue_id',issue_id,'issue_title',issue_title) FROM sessions WHERE $W LIMIT 1;\" 2>/dev/null\n    ;;\n  cancel)\n    if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n    ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n    if [ -n \"$ISSUE\" ]; then\n      pkill -f claude 2>/dev/null\n      rm -f \"$GW/gateway.lock\"\n      sqlite3 \"$DB\" \"DELETE FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM queue WHERE issue_id='$ISSUE';\"\n      echo \"CANCELLED:$ISSUE\"\n    else\n      echo \"NO_SESSION\"\n    fi\n    ;;\n  pause)\n    if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n    ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n    if [ -n \"$ISSUE\" ]; then\n      sqlite3 \"$DB\" \"UPDATE sessions SET paused=1 WHERE issue_id='$ISSUE';\"\n      echo \"PAUSED:$ISSUE\"\n    else\n      echo \"NO_SESSION\"\n    fi\n    ;;\n  resume)\n    if [ -n \"$ARG0\" ]; then W=\"issue_id='$ARG0'\"; else W=\"is_current=1\"; fi\n    ISSUE=$(sqlite3 \"$DB\" \"SELECT issue_id FROM sessions WHERE $W LIMIT 1;\")\n    if [ -n \"$ISSUE\" ]; then\n      sqlite3 \"$DB\" \"UPDATE sessions SET paused=0 WHERE issue_id='$ISSUE';\"\n      echo \"RESUMED:$ISSUE\"\n    else\n      echo \"NO_SESSION\"\n    fi\n    ;;\n  *) echo \"UNKNOWN_SUB:$SUB\" ;;\nesac",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "format-session",
      "name": "Format Session Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2272,
        272
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst sub = $('Detect Command').first().json.sub || 'current';\nconst output = stdout.trim();\nlet body = '';\n\nif (sub === 'current') {\n  const raw = output.replace('SESSION:', '');\n  if (!raw || raw === 'NO_SESSION') body = 'No active session.';\n  else {\n    try {\n      const s = JSON.parse(raw);\n      body = 'Current session:\\nIssue: ' + s.issue_id + '\\nMessages: ' + s.message_count + '\\nPaused: ' + (s.paused ? 'Yes' : 'No');\n    } catch(e) { body = 'Error: ' + raw.substring(0, 200); }\n  }\n} else if (sub === 'list') {\n  try {\n    const rows = JSON.parse(output || '[]');\n    body = rows.length ? rows.map((s, i) => (i+1) + '. ' + s.issue_id + (s.is_current ? ' (current)' : '')).join('\\n') : 'No active sessions.';\n  } catch(e) { body = 'Error: ' + output.substring(0, 200); }\n} else if (sub === 'done') {\n  if (!output) body = 'No active session to end.';\n  else {\n    try { const s = JSON.parse(output); body = s.session_id ? 'Ending session for ' + s.issue_id + '...' : 'No active session.'; } catch(e) { body = 'No active session.'; }\n  }\n} else if (sub === 'cancel') {\n  body = output.startsWith('CANCELLED:') ? 'Session ' + output.split(':')[1] + ' cancelled.' : output === 'NO_SESSION' ? 'No session to cancel.' : output;\n} else if (sub === 'pause') {\n  body = output.startsWith('PAUSED:') ? 'Session ' + output.split(':')[1] + ' paused.' : output === 'NO_SESSION' ? 'No session to pause.' : output;\n} else if (sub === 'resume') {\n  body = output.startsWith('RESUMED:') ? 'Session ' + output.split(':')[1] + ' resumed.' : output === 'NO_SESSION' ? 'No session to resume.' : output;\n} else { body = 'Unknown: ' + sub; }\n\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "handle-issue",
      "name": "Handle Issue Command",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2064,
        480
      ],
      "parameters": {
        "command": "=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'status' }}\"\nRAW_ARG0=\"{{ $(\"Detect Command\").first().json.args[0] || '' }}\"\nYT_URL=\"{{ $(\"Gateway Config\").first().json.YOUTRACK_URL }}\"\n\nARG0=$(echo \"$RAW_ARG0\" | tr '[:lower:]' '[:upper:]')\n\ncase \"$SUB\" in\n  status)\n    curl -s --max-time 10 \\\n      -H \"Authorization: Bearer $YT_TOKEN\" \\\n      \"$YT_URL/api/issues?query=State:+%7BIn+Progress%7D&fields=idReadable,summary,customFields(name,value(name))\" 2>&1\n    ;;\n  info)\n    [ -z \"$ARG0\" ] && echo \"USAGE: !issue info <id>\" && exit 0\n    curl -s --max-time 10 \\\n      -H \"Authorization: Bearer $YT_TOKEN\" \\\n      \"$YT_URL/api/issues/$ARG0?fields=idReadable,summary,description,customFields(name,value(name))\" 2>&1\n    ;;\n  *) echo \"Available: !issue status, !issue info <id>\" ;;\nesac",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "format-issue",
      "name": "Format Issue Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2272,
        432
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst stdout = $input.first().json.stdout || '';\nconst output = stdout.trim();\nlet body = '';\n\ntry {\n  const data = JSON.parse(output);\n  if (Array.isArray(data)) {\n    body = data.length === 0 ? 'No issues in progress.' : data.map(i => i.idReadable + ': ' + (i.summary || '\u2014')).join('\\n');\n  } else if (data.idReadable) {\n    body = data.idReadable + ': ' + (data.summary || '\u2014') + '\\n' + (data.description || 'No description').substring(0, 500);\n  } else { body = output.substring(0, 1000); }\n} catch(e) { body = output || 'No response from YouTrack.'; }\n\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "handle-pipeline",
      "name": "Handle Pipeline Command",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2064,
        640
      ],
      "parameters": {
        "command": "=SUB=\"{{ $(\"Detect Command\").first().json.sub || 'status' }}\"\nGL_URL=\"{{ $(\"Gateway Config\").first().json.GITLAB_URL }}\"\n\ncase \"$SUB\" in\n  status)\n    curl -s --max-time 10 \\\n      -H \"PRIVATE-TOKEN: $GL_TOKEN\" \\\n      \"$GL_URL/api/v4/projects?membership=true&simple=true\" | \\\n      python3 -c \"import sys,json; projects=json.load(sys.stdin); [print(p['name'] + ': ' + p.get('default_branch','main')) for p in projects[:10]]\" 2>&1\n    ;;\n  *) echo \"Available: !pipeline status\" ;;\nesac",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "format-pipeline",
      "name": "Format Pipeline Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2272,
        592
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst output = ($input.first().json.stdout || '').trim();\nconst body = output || 'No pipeline data available.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "handle-help",
      "name": "Handle Help",
      "type": "n8n-nodes-base.code",
      "position": [
        2064,
        832
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst body = 'Claude Gateway Commands:\\n\\n'\n  + '!session current  \u2014 Show current session\\n'\n  + '!session list     \u2014 List all sessions\\n'\n  + '!session done     \u2014 End current session\\n'\n  + '!session cancel   \u2014 Cancel session (no summary)\\n'\n  + '!session pause    \u2014 Pause message delivery\\n'\n  + '!session resume   \u2014 Resume paused session\\n\\n'\n  + '!issue status     \u2014 In-progress issues\\n'\n  + '!issue info <id>  \u2014 Issue details\\n\\n'\n  + '!pipeline status  \u2014 Pipeline overview\\n\\n'\n  + '!system status    \u2014 Server load & memory\\n\\n'\n  + 'Prefix routing: PROJ-4: your message';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "handle-system",
      "name": "Handle System Command",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2064,
        976
      ],
      "parameters": {
        "command": "LOAD=$(uptime | sed 's/.*load average: //') && MEM=$(free -h | awk '/Mem:/{print $3\"/\"$2}') && PROCS=$(pgrep -c -f claude 2>/dev/null || echo 0) && echo \"Load: $LOAD\" && echo \"Memory: $MEM\" && echo \"Claude processes: $PROCS\"",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "format-system",
      "name": "Format System Response",
      "type": "n8n-nodes-base.code",
      "position": [
        2272,
        912
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nconst output = ($input.first().json.stdout || '').trim();\nconst body = output || 'No system data.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "handle-unknown",
      "name": "Handle Unknown Command",
      "type": "n8n-nodes-base.code",
      "position": [
        2064,
        1168
      ],
      "parameters": {
        "jsCode": "const config = $('Gateway Config').first().json;\nif ($json.matrixBody) return [{ json: { matrixBody: $json.matrixBody, ...config } }];\nconst body = 'Unknown command: !' + ($json.command || 'unknown') + '\\nType !help to see available commands.';\nconst matrixBody = JSON.stringify({ msgtype: 'm.notice', body });\nreturn [{ json: { matrixBody, ...config } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "post-cmd-response",
      "name": "Post Command Response",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2496,
        592
      ],
      "parameters": {
        "url": "={{ $json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $json.MATRIX_ROOM_ID }}/send/m.room.message/cmd-{{ Date.now() }}",
        "method": "PUT",
        "options": {},
        "jsonBody": "={{ $json.matrixBody }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "session-end-check",
      "name": "Is Session Done?",
      "type": "n8n-nodes-base.if",
      "position": [
        2720,
        592
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c70feebe",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "={{ $('Handle Session Command').first().json.stdout || '' }}",
              "rightValue": "session_id"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "cleanup-session",
      "name": "Clean Up & End Session",
      "type": "n8n-nodes-base.ssh",
      "position": [
        2944,
        592
      ],
      "parameters": {
        "command": "=DB=\"{{ $(\"Gateway Config\").first().json.DB_PATH }}\"\nGW=\"{{ $(\"Gateway Config\").first().json.CONTEXT_DIR }}\"\nPROJECT=\"{{ $(\"Gateway Config\").first().json.CLAUDE_PROJECT_PATH }}\"\nCLAUDE_BIN=\"{{ $(\"Gateway Config\").first().json.CLAUDE_BINARY }}\"\nSESSION_OUT=\"{{ $(\"Handle Session Command\").first().json.stdout || '' }}\"\n\nSESSION_OUT=$(echo \"$SESSION_OUT\" | tr -d '\\n')\n[ -z \"$SESSION_OUT\" ] && echo \"NO_SESSION\" && exit 0\n\nSID=$(echo \"$SESSION_OUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('session_id',''))\" 2>/dev/null)\nISSUE=$(echo \"$SESSION_OUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('issue_id',''))\" 2>/dev/null)\n[ -z \"$SID\" ] && echo \"NO_SESSION\" && exit 0\n\n# Get summary from Claude\nunset CLAUDECODE\ncd \"$PROJECT\"\ntimeout 60 \"$CLAUDE_BIN\" -r \"$SID\" -p \"Summarize what you worked on in 2-3 sentences\" --output-format json --dangerously-skip-permissions 2>&1\n\n# Archive and clean up\nsqlite3 \"$DB\" \"INSERT INTO session_log (issue_id,issue_title,session_id,started_at,message_count,outcome) SELECT issue_id,issue_title,session_id,started_at,message_count,'done' FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM sessions WHERE issue_id='$ISSUE'; DELETE FROM queue WHERE issue_id='$ISSUE';\"\nrm -f \"$GW/gateway.lock\"\ntouch \"$GW/gateway.cooldown.$ISSUE\"\necho \"SESSION_ENDED:$ISSUE\"",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "post-session-ended",
      "name": "Post Session Ended",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3152,
        592
      ],
      "parameters": {
        "url": "={{ $('Gateway Config').first().json.MATRIX_HOMESERVER }}/_matrix/client/v3/rooms/{{ $('Gateway Config').first().json.MATRIX_ROOM_ID }}/send/m.room.message/end-{{ Date.now() }}",
        "method": "PUT",
        "options": {},
        "jsonBody": "={{ JSON.stringify({ msgtype: 'm.notice', body: 'Session ended. Summary posted to YouTrack.' }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "cred-sticky",
      "name": "Sticky Note Credentials",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        800
      ],
      "parameters": {
        "color": 6,
        "width": 520,
        "height": 508,
        "content": "### Credentials Setup\n\n**Do NOT store API tokens in workflow nodes.**\nUse n8n's credential system instead:\n\n1. **Matrix Bot Token** \u2014 HTTP Header Auth\n   `Authorization: Bearer <token>`\n\n2. **SSH Private Key** \u2014 SSH credential\n   for your Claude Code server\n\n**API tokens for YouTrack & GitLab:**\nSSH command nodes reference `$YT_TOKEN` and\n`$GL_TOKEN` environment variables. Set these\nin `~/.bashrc` on the remote server:\n```\nexport YT_TOKEN=perm-YOUR-TOKEN\nexport GL_TOKEN=glpat-YOUR-TOKEN\n```\n\nThis keeps tokens out of the workflow JSON\nand uses the server's environment instead."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "none"
  },
  "versionId": "929c4f9a-8b21-4f30-97d1-7b2f6fc1fdd4",
  "connections": {
    "Check Lock": {
      "main": [
        [
          {
            "node": "Is Locked?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Locked?": {
      "main": [
        [
          {
            "node": "Post Busy Notice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Read Session & Acquire Lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Help": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Messages?": {
      "main": [
        [
          {
            "node": "Detect Command",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Command Router": {
      "main": [
        [
          {
            "node": "Check Lock",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Session Command",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Help",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Issue Command",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Pipeline Command",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle System Command",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Unknown Command",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Command": {
      "main": [
        [
          {
            "node": "Command Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Sync Token": {
      "main": [
        [
          {
            "node": "Poll Matrix Sync",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Every 30s": {
      "main": [
        [
          {
            "node": "Get Sync Token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Messages": {
      "main": [
        [
          {
            "node": "Has Messages?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Session Done?": {
      "main": [
        [
          {
            "node": "Clean Up & End Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Matrix Sync": {
      "main": [
        [
          {
            "node": "Extract Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Issue Command": {
      "main": [
        [
          {
            "node": "Format Issue Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Issue Response": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle System Command": {
      "main": [
        [
          {
            "node": "Format System Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Claude Response": {
      "main": [
        [
          {
            "node": "Post Response to Matrix",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Command Response": {
      "main": [
        [
          {
            "node": "Is Session Done?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resume Claude Session": {
      "main": [
        [
          {
            "node": "Parse Claude Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean Up & End Session": {
      "main": [
        [
          {
            "node": "Post Session Ended",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format System Response": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Session Command": {
      "main": [
        [
          {
            "node": "Format Session Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Unknown Command": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Session Response": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Pipeline Command": {
      "main": [
        [
          {
            "node": "Format Pipeline Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Response to Matrix": {
      "main": [
        [
          {
            "node": "Release Lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Pipeline Response": {
      "main": [
        [
          {
            "node": "Post Command Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Session & Acquire Lock": {
      "main": [
        [
          {
            "node": "Resume Claude Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}