M
MeshWorld.
Agent Skills Agentic AI Tutorial Node.js GitHub Octokit Real World 8 min read

Build a GitHub Issue Creator Skill for Your AI Agent

By Vishnu Damwala

I was doing a code review with my agent. It flagged three potential bugs — a race condition, a missing null check, and a deprecated API call. Then it asked: “Want me to file these as GitHub issues?”

I said yes. Twenty seconds later, three properly formatted issues appeared in the repo — with labels, a clear description, steps to reproduce, and the affected file paths. All from one conversation.

This is the point where agent skills stop being demos and start being genuinely useful. But getting to this point safely requires thinking about a few things most tutorials skip.


Why this is “real world”

The weather skill from previous posts is read-only — it fetches data, nothing more. This skill is different:

  • Authentication — requires a GitHub token with the right scopes
  • Side effects — creates real, persistent records in a real repository
  • Rate limits — GitHub API has limits; hitting them mid-batch is awkward
  • Safety — an agent creating issues in the wrong repo, or creating duplicates, is embarrassing

Each of these needs to be handled before you ship this to a production agent.


Setup

npm install @octokit/rest dotenv

Create a .env file:

GITHUB_TOKEN=ghp_your_token_here

Your token needs only the repo scope (or public_repo for public repositories only). Don’t use a personal access token with full permissions — use the minimum scope you need.

If you’re using the token with OpenClaw, use the env gating pattern from the OpenClaw skills post:

metadata:
  openclaw:
    requires:
      env: ["GITHUB_TOKEN"]

The skill won’t activate unless GITHUB_TOKEN is set.


The skill implementation

// github-issues.js
import { Octokit } from "@octokit/rest";
import "dotenv/config";

function getOctokit() {
  if (!process.env.GITHUB_TOKEN) {
    throw new Error("GITHUB_TOKEN environment variable is not set.");
  }
  return new Octokit({ auth: process.env.GITHUB_TOKEN });
}

// Parse "owner/repo" format into { owner, repo }
function parseRepo(repoString) {
  const parts = repoString.split("/");
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
    return { error: `Invalid repo format: "${repoString}". Expected "owner/repo".` };
  }
  return { owner: parts[0], repo: parts[1] };
}

Duplicate detection

Before creating an issue, search for existing open issues with similar titles. This prevents the agent from filing the same bug twice if you run it on the same codebase multiple times.

export async function search_github_issues({ repo, query, state = "open" }) {
  const parsed = parseRepo(repo);
  if (parsed.error) return parsed;

  let octokit;
  try {
    octokit = getOctokit();
  } catch (err) {
    return { error: err.message };
  }

  try {
    const response = await octokit.search.issuesAndPullRequests({
      q: `${query} repo:${repo} is:issue is:${state}`,
      per_page: 5,
      sort: "updated"
    });

    const issues = response.data.items.map(issue => ({
      number: issue.number,
      title: issue.title,
      url: issue.html_url,
      state: issue.state,
      createdAt: issue.created_at
    }));

    return { found: issues.length, issues };
  } catch (err) {
    return { error: `GitHub search failed: ${err.message}` };
  }
}

Create issue with dry-run mode

export async function create_github_issue({
  repo,
  title,
  body,
  labels = [],
  assignees = [],
  dryRun = false
}) {
  if (!title) return { error: "Issue title is required." };
  if (!repo) return { error: "Repo (owner/repo) is required." };

  const parsed = parseRepo(repo);
  if (parsed.error) return parsed;

  // Dry run: return what would be created without making any API calls
  if (dryRun) {
    return {
      dryRun: true,
      wouldCreate: {
        repo,
        title,
        body: body ?? "(no description)",
        labels,
        assignees
      },
      message: "This is a dry run. No issue was created. Set dryRun: false to create it."
    };
  }

  let octokit;
  try {
    octokit = getOctokit();
  } catch (err) {
    return { error: err.message };
  }

  try {
    const response = await octokit.issues.create({
      owner: parsed.owner,
      repo: parsed.repo,
      title,
      body: body ?? "",
      labels: labels.length ? labels : undefined,
      assignees: assignees.length ? assignees : undefined
    });

    return {
      created: true,
      number: response.data.number,
      title: response.data.title,
      url: response.data.html_url,
      repo
    };
  } catch (err) {
    // Handle specific GitHub API errors
    if (err.status === 403) return { error: "Permission denied. Check your GITHUB_TOKEN scopes." };
    if (err.status === 404) return { error: `Repository not found: "${repo}". Check the owner/repo format.` };
    if (err.status === 422) return { error: `Validation failed: ${err.message}. Check labels and assignees exist.` };
    return { error: `GitHub API error: ${err.message}` };
  }
}

Tool definitions

Write the create_github_issue description to encourage the model to produce well-formatted issue bodies:

export const githubTools = [
  {
    name: "search_github_issues",
    description:
      "Search for existing GitHub issues in a repository. " +
      "Always use this BEFORE creating a new issue to check for duplicates. " +
      "Returns matching open issues with their numbers and URLs.",
    input_schema: {
      type: "object",
      properties: {
        repo: { type: "string", description: "Repository in 'owner/repo' format" },
        query: { type: "string", description: "Search terms — use key words from the issue title" },
        state: { type: "string", enum: ["open", "closed", "all"], description: "Issue state filter (default: open)" }
      },
      required: ["repo", "query"]
    }
  },
  {
    name: "create_github_issue",
    description:
      "Create a new GitHub issue. " +
      "Write a clear, descriptive title (not generic like 'Bug found'). " +
      "The body should include: what the problem is, steps to reproduce, expected vs actual behavior, " +
      "and the affected file/function if known. " +
      "Set dryRun: true to preview what would be created before actually creating it.",
    input_schema: {
      type: "object",
      properties: {
        repo: { type: "string", description: "Repository in 'owner/repo' format" },
        title: { type: "string", description: "Issue title — be specific, not generic" },
        body: { type: "string", description: "Issue body with full description, steps to reproduce, expected behavior" },
        labels: { type: "array", items: { type: "string" }, description: "Label names (must already exist in the repo)" },
        assignees: { type: "array", items: { type: "string" }, description: "GitHub usernames to assign" },
        dryRun: { type: "boolean", description: "If true, preview without creating (default: false)" }
      },
      required: ["repo", "title"]
    }
  }
];

Full working example

// github-agent.js
import Anthropic from "@anthropic-ai/sdk";
import { search_github_issues, create_github_issue } from "./github-issues.js";

const client = new Anthropic();
const toolFunctions = { search_github_issues, create_github_issue };

async function chat(userMessage) {
  const messages = [{ role: "user", content: userMessage }];
  let response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 2048,
    tools: githubTools,
    messages
  });

  while (response.stop_reason === "tool_use") {
    const toolBlocks = response.content.filter(b => b.type === "tool_use");
    const toolResults = [];

    for (const toolBlock of toolBlocks) {
      const fn = toolFunctions[toolBlock.name];
      const result = fn ? await fn(toolBlock.input) : { error: "Unknown tool" };
      console.log(`[${toolBlock.name}]`, JSON.stringify(result).slice(0, 150));
      toolResults.push({ type: "tool_result", tool_use_id: toolBlock.id, content: JSON.stringify(result) });
    }

    messages.push(
      { role: "assistant", content: response.content },
      { role: "user", content: toolResults }
    );

    response = await client.messages.create({
      model: "claude-sonnet-4-6", max_tokens: 2048, tools: githubTools, messages
    });
  }

  return response.content[0].text;
}

// Example: dry run first, then real creation
const result = await chat(
  "In the repo vishnu/my-project, create an issue about a missing null check in the auth middleware. Use dry run first."
);
console.log(result);

What the agent does:

  1. Calls search_github_issues to check if a null check issue already exists
  2. If none found, calls create_github_issue with dryRun: true first
  3. Shows you the preview
  4. You confirm — agent calls create_github_issue with dryRun: false

Extending to Slack

The same pattern works for any action skill. Here’s send_slack_message — identical structure, different API:

// slack-message.js
export async function send_slack_message({ channel, message, dryRun = false }) {
  if (!channel || !message) return { error: "Channel and message are required." };

  if (dryRun) {
    return { dryRun: true, wouldSend: { channel, message }, message: "Dry run — no message sent." };
  }

  const webhookUrl = process.env.SLACK_WEBHOOK_URL;
  if (!webhookUrl) return { error: "SLACK_WEBHOOK_URL is not set." };

  try {
    const response = await fetch(webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ channel, text: message })
    });

    if (!response.ok) return { error: `Slack returned HTTP ${response.status}` };
    return { sent: true, channel, messageLength: message.length };
  } catch (err) {
    return { error: `Slack error: ${err.message}` };
  }
}

Same pattern: env var check, dry run mode, structured error returns, specific HTTP error handling. Copy this template for any action skill — email, Jira, Linear, Notion, anything with an API.


Deployment checklist

Before giving an agent issue-creation rights in a production repository:

  • Token uses minimum required scopes (repo or public_repo only)
  • Token is in .env, not hardcoded in source
  • dryRun: true is the default for new deployments — change to false only after testing
  • Labels you reference in the tool description actually exist in the target repo
  • You’ve tested with a private test repo before pointing at a production repo
  • The agent’s system prompt specifies which repo(s) it’s allowed to create issues in
  • Rate limiting is handled (Octokit handles GitHub’s secondary rate limits automatically)

What’s next

Unify this skill across Claude and OpenAI: Vercel AI SDK Tools: One API for Claude and OpenAI Skills

Chain this with web search: Chaining Agent Skills: Research, Summarize, and Save

Handle auth and network errors: Handling Errors in Agent Skills: Retries and Fallbacks