Skip to main content

GitHub

Forty-four tools: issues and pull requests, code and branches, Projects v2 boards, releases, workflow runs, labels and milestones. Every write stops and asks a human; three are classified destructive and say so.

npm i @forge/tools-github

Ship the ten you use, not all forty-four

Forty-four catalogue entries cost roughly 1,540 tokens on every turn, and dropping entries at run time is not the answer — we measured a run-time catalogue budget costing 19–23 points of tool-selection accuracy, because a plausible tool that is still resident beats searching for the right one.

So selection happens at wiring time:

import { createStaticCredentialResolver } from "@forge/agentkit/tools";
import { createGitHubToolkit } from "@forge/tools-github";

const resolver = createStaticCredentialResolver({ github: process.env.GITHUB_TOKEN ?? "" });

createGitHubToolkit({
credentialRef: "github",
resolver,
include: ["github_search_issues", "github_get_issue", "github_comment", "github_add_labels"],
});

exclude is the other direction, for "everything except the dangerous ones":

createGitHubToolkit({
credentialRef: "github",
resolver,
exclude: ["github_delete_file", "github_merge_pull_request"],
});

A name that does not exist is refused at construction, with a suggestion. This matters most for exclude: a typo silently ignored ships the tool you believed you had removed, and nothing anywhere would say so.

createGitHubToolkit was given exclude names this toolkit does not have: github_serch_issues.
Did you mean github_search_issues?

Tools

Issues and pull requests

ToolEffectApprovalNotes
github_search_issuesreadneverGitHub's query syntax, across repositories
github_get_issuereadneverBody, labels, assignees, milestone, comment count
github_list_issuesreadneverPaginated; reports truncated
github_list_pull_requestsreadneverFilterable by state and base branch
github_get_pull_requestreadneverChanged files with counts — not the diff, which is unbounded
github_search_pull_requestsreadnever
github_create_issueexternal-writealways
github_update_issueexternal-writealwaysOnly the fields you pass; labels and assignees replace
github_close_issueexternal-writealwaysCarries completed or not_planned — closing without saying which loses the decision
github_reopen_issueexternal-writealwaysClears not_planned too
github_create_pull_requestexternal-writealwaysHead, base, title, body, draft
github_update_pull_requestexternal-writealwaysIncluding draft-to-ready
github_review_pull_requestexternal-writealwaysAPPROVE / REQUEST_CHANGES / COMMENT
github_commentexternal-writealwaysIssues and pull requests
github_close_pull_requestexternal-writealwaysNot a merge; nothing reaches the base
github_merge_pull_requestdestructivealwaysCannot be undone by another call

Code and repository

ToolEffectApprovalNotes
github_search_codereadneverAcross repositories you can see
github_get_filereadneverContents at a ref
github_list_directoryreadneverSays so if you named a file
github_list_commitsreadneverFilterable by path and author
github_get_commitreadneverMessage, author, changed files
github_list_branchesreadneverWith protection status
github_list_tagsreadnever
github_create_branchexternal-writealwaysFrom a branch name, tag or sha — resolved for you
github_write_fileexternal-writealwaysCreate or update; fetches the current sha itself
github_delete_filedestructivealwaysRecoverable through history, not to whoever asked

There is deliberately no github_create_file beside github_update_file. That split is the contents API's — an update needs a sha, a create refuses one — and it is not a distinction the caller has. A model asked to fix a typo does not know whether the file exists, and guessing wrong earns a 422 it cannot interpret.

Projects v2

Every one of these takes human identifiers and resolves node ids internally. Projects v2 addresses the owner, the project, each item, each field and each option by base64 node id, and none of those is knowable to a model that was asked to "move issue 42 to Done".

ToolEffectApprovalNotes
github_list_projectsreadneverFor a user or an organisation, without being told which
github_get_projectreadneverFields with their options — read this before setting one
github_create_projectexternal-writealwaysResolves the owner id from the login
github_add_project_itemexternal-writealwaysTakes owner/repo#number
github_set_project_fieldexternal-writealwaysfield: "Status", value: "Done"
github_remove_project_itemdestructivealwaysRemoves from the board; does not delete the issue

A field or value that does not match fails naming the valid ones:

"Dnoe" is not an option for the field "Status". Its options are: Todo, In Progress, Done.

That is the difference between a tool a model can use and one it can only attempt.

Releases, workflows, labels, milestones

ToolEffectApprovalNotes
github_list_releasesreadnever
github_get_releasereadneverBy tag, or latest
github_list_workflow_runsreadneverBy workflow, branch, status
github_get_workflow_runreadneverPer-job status, and which step failed
github_get_workflow_run_logsreadneverThe last 60 KB of one job's log, truncation reported
github_list_labelsreadneverRead before adding — an unknown label is created
github_list_milestonesreadneverOpen and closed issue counts
github_create_releaseexternal-writealwaysDraft by request; the tag must exist unless you pass a target
github_rerun_workflowexternal-writealwaysAll jobs, or only the failed ones
github_dispatch_workflowexternal-writealwaysThe workflow must declare workflow_dispatch
github_add_labelsexternal-writealwaysAdds; does not replace
github_remove_labelexternal-writealwaysOne at a time, so a wrong call costs one label

Wire it up

import { createAgent } from "@forge/agentkit/providers";

const agent = createAgent({
manifest: {
id: "maintainer",
name: "Maintainer",
instructions: "Help triage issues. Say what you are about to change before you change it.",
modelPolicy: { role: "smart" },
},
tools: [
createGitHubToolkit({
credentialRef: "github",
resolver,
include: ["github_search_issues", "github_get_issue", "github_add_labels", "github_comment"],
}),
],
});

For GitHub Enterprise Server, pass baseUrl: "https://github.example.com/api/v3".

Credentials and scopes

A personal access token is the quickest start; a GitHub App's installation token is the upgrade path and needs no change here — the resolver returns a different credential.

A token that can read code often cannot write a project. Projects v2 is the trap: it is a separate scope on a classic PAT and a separate permission on an App, and the failure arrives from GraphQL rather than as an HTTP status. Scopes by group:

GroupClassic PATFine-grained PAT / GitHub App
Issues and pull requests — readsrepo (or none for public)Issues: read, Pull requests: read, Metadata: read
Issues and pull requests — writesrepoIssues: write, Pull requests: write
Code and repository — readsrepo (or none for public)Contents: read, Metadata: read
Code and repository — writesrepoContents: write
Projects v2 — readsread:projectProjects: read
Projects v2 — writesprojectProjects: write
ReleasesrepoContents: write
Workflow runs — reads and re-runsrepoActions: read, then Actions: write to re-run or dispatch
Labels and milestonesrepoIssues: write

Two notes that cost an afternoon each if you find them the hard way:

  • project is an organisation-level scope. A classic PAT with repo but not project reads issues perfectly and fails every Projects v2 call with FORBIDDEN.
  • workflow is not actions. The classic workflow scope is for committing workflow files; re-running and dispatching need repo on a classic PAT, and Actions: write on a fine-grained one.

A missing scope comes back as an unauthorized failure — not retryable — naming GitHub's own message, so the transcript says what is wrong rather than "something failed".

Behaviour worth knowing

Rate limits are retryable, and a scope problem is not. GitHub's 403 rate limit exceeded and 429 become rate_limited with retryable: true, so the runtime backs off. A plain 401/403 becomes unauthorized with retryable: false and a message that mentions scopes, because a model told "forbidden" retries with different arguments — which is never the fix.

A GraphQL 200 carrying errors is a failure. Projects v2 is GraphQL, and GraphQL reports application errors with an HTTP 200 and data: null. The transport reads the envelope and surfaces the first message, so you get Could not resolve to a ProjectV2 with the number 99 rather than an internal error about reading a field off null. FORBIDDEN and INSUFFICIENT_SCOPES in that envelope map to unauthorized.

Pagination stops at a ceiling and says so. Reads follow up to five pages of up to 100 and return truncated: true if there was more, rather than implying they saw everything. Search reports truncated when GitHub's own incomplete_results is set, or when it counted more than it returned.

A dispatch does not tell you what it started. GitHub answers workflow_dispatch with a 204 and no run id. The tool says so instead of inventing one, or reporting a run from a later list call that might have started in between.

Issue and file content is untrusted. It arrives fenced. An issue body instructing the model to merge something is data, and the merge would still stop for approval.

Limits

Still declined, each for a reason rather than because the list stopped:

Not offeredWhy
Force push, and moving a branch to a different commitDestroys commits that exist nowhere else. github_create_branch fails if the branch exists rather than moving it
Branch and tag deletionA deleted branch takes unmerged commits with it, and nothing in the API distinguishes "merged" from "abandoned"
Repository deletion, transfer, visibility changesIrreversible, and public-visibility changes are irreversible in public. Not an agent's decision
deleteProjectV2Deletes a board and every field value on it. github_remove_project_item takes one item off, which is the reversible unit
Copilot review requestsSpends someone else's budget and posts a review attributed to the repository
Secret scanning alertsReading them means reading secrets. Out of scope while the credential path is app-side sealed
Organisation administration — members, teams, roles, settingsAccess-granting is the one act where a wrong call cannot be walked back by another call
Releases: deleting, and asset uploadDeletion is irreversible; asset upload is multipart to a second host, which is the same deferral as Slack's upload_file

Three of these were declined in earlier revisions and are now included, each for a stated reason: github_create_release (it announces, it does not deploy, and a release can be deleted), github_dispatch_workflow (unlike a shell, its blast radius is a workflow file somebody reviewed and merged), and github_get_workflow_run_logs (bounded to the last 60 KB, and reading logs is the whole task after a red build).