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:
- Calls
search_github_issuesto check if a null check issue already exists - If none found, calls
create_github_issuewithdryRun: truefirst - Shows you the preview
- You confirm — agent calls
create_github_issuewithdryRun: 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 (
repoorpublic_repoonly) - Token is in
.env, not hardcoded in source -
dryRun: trueis the default for new deployments — change tofalseonly 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
Related Reading.
Agent Skills with Google Gemini: Function Calling Guide
Complete guide to Gemini function calling — define tools, handle function_call responses, return results, and compare syntax with Claude and OpenAI. Node.js.
Vercel AI SDK Tools: One API for Claude and OpenAI Skills
Vercel AI SDK's unified tool interface works with Claude, OpenAI, and Gemini. Write your skill once and switch AI providers without rewriting the agent loop.
Chaining Agent Skills: Research, Summarize, and Save
Build a skill chain where an agent searches the web, summarizes findings, and saves results to a file — all from a single prompt. Full Node.js walkthrough.