builtin__ namespace. Same schema, same validation, same catalog — the namespace just says who ships them. They’re grouped into packs, enabled per pack:
llm and data packs are always on — no config needed. Unknown pack names fail at startup, not at plan time. To customize a built-in, copy its YAML into a tools directory and reference your user__ copy from plans — built-ins themselves never change out from under you except with a graph release.
Runtime requirements
Built-ins are mostlyexec tools: they shell out and inherit the process environment. The github pack expects git, jq, and gh on PATH, with GH_TOKEN (or gh auth) for the gh_* tools; the slack pack expects curl and jq, with SLACK_BOT_TOKEN. All are present on GitHub Actions runners and baked into the release container image.
The llm pack (always on)
builtin__infer
Generic LLM inference — the prompt tool kind exposed as one callable tool, so a plan can add an inference step without authoring a tool YAML first. Plain text out by default; pass an output_schema and the result is structured JSON validated against it (with one repair pass), so every declared field is guaranteed present and downstream template references like {{Ex.score}} are safe. Read-only.
The
output_schema must be an object schema — give it an explicit top-level "type": "object" and a properties map, e.g. {"type":"object","properties":{"score":{"type":"number"}},"required":["score"]}. A bare {properties, required} with the type omitted is coerced to an object rather than rejected, but write it out: the schema is also what the provider validates against.
To analyze each element of a list, call infer inside a map body with {{item}} interpolated per call rather than interpolating the whole list into one instruction — see per-item inference for why and when not to.
For a fixed inference step you reuse across plans (a classifier, a reviewer), still prefer a dedicated user__ prompt tool — its prompt and schema live in one reviewed file instead of being repeated in every plan.
The data pack (always on)
builtin__reshape
Build a value in a target JSON shape — the reshape tool kind exposed as one callable tool, so a plan can remap keys without authoring a tool YAML or shelling out to jq. Pure: no process, no LLM, no side effects. Read-only.
The
shape is a JSON object whose keys are literal and whose leaf strings are templates over the surrounding step’s data — an earlier result as {{Ex.field}}, the current map element as {{item}}, a plan input as {{input.x}} — exactly like any other step input. The pipeline renders it once (like a map’s over), so the result is that shape with every template resolved, with the same typed splice: a leaf that is exactly one tag keeps the source value’s type (numbers stay numbers, arrays stay arrays); mixed text like "PR #{{item.number}}" renders to a string.
user__ exec tool. A shape referencing a field the data lacks fails the step’s input render, like any other bad path in a step input.
Because that single render is the pipeline’s, the tool itself never re-renders the shape: whatever the templates resolved to is returned verbatim, so values that happen to contain {{ … }} — an LLM quoting a Helm chart, a GitHub Actions expression, a mustache partial — pass through untouched instead of being parsed as graph templates. The flip side is that builtin__reshape is a plan-step tool: called directly by an agent step or from ask/chat, where inputs are model-authored and not plan-rendered, it returns the shape it was given.
To reshape every element of a list, call reshape inside a map body over the list and reference {{item}} in the shape.
The github pack
Building blocks for PR- and release-shaped plans — see the CI checks cookbook for them in use.
builtin__gh_pr_meta
Metadata for a pull request — the starting point for anything that diffs or reviews a PR. Read-only.
builtin__gh_pr_comment
Post or update a comment on the PR conversation. With a marker, the body carries it as a hidden HTML tag (<!-- marker -->) and repeated runs find and edit that comment — so several plans can each maintain their own comment on one PR. Without one, updates the token identity’s most recent comment (--edit-last --create-if-none), so repeated runs keep a single comment instead of stacking. Side-effecting (read_only: false).
Output:
{ "text": "<comment URL>" }.
builtin__gh_pr_ticket
Ticket identifier (e.g. LN-1234) from a PR’s metadata — deterministic regex extraction, zero LLM calls, so plans don’t burn an inference on what a pattern match can do. Scans the head branch name, then the title, then the body; first match wins and is normalized to uppercase-with-dash form (LN 1234, ln-1234 → LN-1234). Read-only.
The default pattern requires a separator (dash or space) between the letters and digits, so glued technical words like arm64, utf8, and sha256 don’t false-match as tickets. If your tracker uses a glued form (ln1234), override pattern accordingly.
Every field is always present, so template references like
{{Ex.ticket}} never dangle; a PR without a ticket is {found: false}, not an error.
builtin__gh_release
Metadata for a release — tag, title, notes body, url, publication date — plus previous_tag, the tag of the release published before it. That last field is what makes this the entry point for release-shaped plans: it is the base ref for a git_log or git_diff over the range that shipped. Omit tag for the latest release. Read-only.
previous_tag is resolved by publication date rather than list position, so a prerelease target still finds the last stable release before it. With the default include_prereleases: false, a stable release’s previous_tag is the last stable release — so a summary built on it covers everything since users last saw a release, not just since the last release candidate.
The first release in a repository has no predecessor: previous_tag is null, not an error. Gate on it before diffing —
builtin__gh_pr_inline_comments
Post inline review comments anchored to lines of the PR diff. Each entry posts as its own API call, so one bad anchor can’t sink the rest; entries without a line, or whose anchor the API rejects (the line must be part of the diff), pass through unposted. Every comment embeds the marker as a hidden HTML tag, and comments from earlier runs carrying the same marker are deleted before posting — repeated runs refresh in place instead of stacking. Side-effecting (read_only: false).
Output:
{ "comments": [...] } — the input entries in order, each with url added when its comment posted. Extra entry keys survive the round trip, so a downstream template can render a summary (with links) from this tool’s output alone.
Inline comments never block a merge by themselves: they post as plain review comments (not a REQUEST_CHANGES review), so they only gate merging on repos with Require conversation resolution before merging enabled.
Delete-and-repost is stateless by design — nothing survives between runs, including human replies on the comments. When findings should live across runs as conversations (stay open, get replied to, resolve when fixed), use the thread-lifecycle pair below instead: gh_pr_thread_sync to post and transition, gh_pr_review_threads to read back.
builtin__gh_pr_review_threads
The read half of the thread-lifecycle pair: list the review threads a plan previously posted on a PR, as structured findings. A thread is recognized by the hidden <!-- <marker> {json} --> payload tag gh_pr_thread_sync embeds in its first comment; everything else on the PR is ignored. The embedded payload merges with thread metadata into one flat object per finding, so downstream templates reference {{label}}, {{severity}}, etc. directly. Read-only.
Each finding’s state is a heuristic from GitHub’s resolution data: an unresolved thread is open; a thread resolved by the same login that posted it is resolved (our own automation closed it); a thread resolved by anyone else is declined (a human closed it). Replies after the first comment come along verbatim (replies: [{author, body}]) so an inference step can read a “wontfix” for what it is.
Output:
{ "findings": [...], "count", "open_count", "skipped", "has_findings" } — each finding is the payload’s keys plus thread_id, state, path, line, is_outdated, url, replies, and reply_count. Tagged threads whose payload is missing or lacks the keys label, severity, invariant, file, line, finding are skipped (counted in skipped, never fatal) — they can’t round-trip.
builtin__gh_pr_thread_sync
The write half of the thread-lifecycle pair: post new findings as inline comments and apply per-thread transitions to existing ones — in one call, without ever deleting an open thread or a human reply. New comments embed the marker’s hidden JSON payload so gh_pr_review_threads can read them back on the next run. Each create and each transition is its own API call, so one failure can’t sink the rest: a failed transition comes back applied: false and the thread simply stays open. Side-effecting (read_only: false).
A create’s
payload is the structured finding embedded as the hidden tag — keep it small and flat, and make sure it carries the keys gh_pr_review_threads requires. Entries without a line, or whose anchor the API rejects, pass through unposted, exactly like gh_pr_inline_comments. A thread’s transition is none (leave untouched), resolve, or decline — both of the latter reply with note (a sensible default when empty) and resolve the thread via GraphQL, so the distinction is the message, not the mechanics.
Output: { "creates": [...], "threads": [...], "created_count", "resolved_now_count", "declined_now_count", "still_open_count", "transitions_count", "has_creates", "has_transitions" } — the input entries in order (creates gain url when posted; threads gain applied and transitioned), plus the lifecycle counts, so a summary comment renders from this tool’s output alone.
builtin__git_diff
Unified diff between two refs (merge-base three-dot diff), path-scoped, truncated to a byte budget — sized for LLM consumption. A diff over budget ends with a [diff truncated: showing X of Y bytes] marker line, so a consumer can tell a complete diff from a cut one; an unresolvable ref fails the call instead of returning an empty diff. Read-only. Needs a checkout with enough history to resolve both refs (fetch-depth: 0 in CI).
Output:
{ "text": "<diff>" } — empty string when nothing changed, which pairs with an empty exit gate.
builtin__git_log
Commits between two refs, newest first, with the PR number parsed out of each subject. The high-signal, low-cost input for changelog and release-summary steps: commit subjects carry the intent that a diff makes an LLM infer, at a fraction of the tokens. Read-only. Needs enough history to resolve both refs (fetch-depth: 0 in CI).
The range is two-dot (base..head) — “commits that landed since base” — not the three-dot merge-base form git_diff takes. Pair it with gh_release’s previous_tag as base and the release tag as head.
pr is the pull request number from a trailing (#123) in the subject (the form GitHub’s squash merge writes), or null — so a summary can link each change without a second API call. count counts what came back; total counts the range, so truncated is exact rather than inferred.
Merges are excluded by default: on a squash-merge history they add nothing, and on a merge-commit history the merge subject just restates the branch it brought in. An empty range is {count: 0}, not an error — gate on it before spending an inference. An unresolvable ref fails the call loudly rather than returning an empty list a gate would read as “nothing shipped”.
builtin__git_changed_files
Files changed between two refs (merge-base diff), filtered to a path prefix — the cheap input for gating before any LLM call, and the grounding for review plans that read files at the head. Read-only. Fails loudly on bad refs rather than returning an empty list a gate would misread; no changes is {files: [], count: 0, changes: []}, not an error.
status is added, modified, deleted, renamed, copied, or type_changed; old_path is the previous path for renamed/copied entries and null otherwise. Rename detection is pinned on (-M) so a rename is always one renamed entry, never a delete+add pair that varies with the machine’s diff.renames config. additions/deletions are line counts (0 with binary: true for binary files).
A deleted file’s path appears in files and changes — it changed — but does not exist at head, so git_file at the head sha fails on it by design. Select before you read: filter the changes to status ne deleted and map git_file over {{Ex.items}} reading {{item.path}}. The same applies to a renamed entry’s old_path.
builtin__git_file
Contents of a file at a ref (git show <ref>:<path>), truncated to a byte budget — so a review plan can read a file as it exists at the PR head instead of whatever is checked out. Over-budget content ends with a [file truncated: showing X of Y bytes] marker line, same convention as git_diff; an unresolvable ref or missing path fails the call instead of returning empty content. Read-only.
Output:
{ "text": "<content>" }.
builtin__git_grep
Search tracked file contents at a ref (git grep -n, POSIX extended regex), optionally scoped to pathspecs — check code at the PR head without an LLM call or a checkout switch. No match at all is {matches: [], count: 0}, not an error; a bad ref fails loudly instead of reading as “nothing found”. Read-only. Git only — no rg required.
The slack pack
Delivery for plans whose result a human has to see — a report, a review verdict, an alert. With this pack the post is a step, so a refused delivery fails the run instead of vanishing into a shell pipe.
Setup, once per workspace: create a Slack app, give it the chat:write bot scope, install it to the workspace, invite it to each channel you post to (/invite @your-app), and export its bot token (xoxb-…) as SLACK_BOT_TOKEN. The pack reads the token from the environment exactly like GH_TOKEN — no config entry, and no token in a plan.
The button below opens Slack’s app-creation flow with that configuration already filled in — pick a workspace, review, and click Create, then Install to Workspace and copy the bot token from OAuth & Permissions.
Create the Graph CI Slack app
Pre-filled from
app-manifest.json, the manifest this pack is built against. Paste that file into From a manifest by hand if you’d rather not follow a link.builtin__slack_post_message
Post a message to a channel (chat.postMessage). Pass thread_ts to post as a reply inside an existing thread instead of a new message: the ts this tool returns is exactly what a later call passes back, so a plan can post a summary and thread the details under it. Side-effecting (read_only: false).
Slack answers HTTP 200 even when it refuses a post (
{"ok": false, "error": "channel_not_found"}), so the tool inspects ok and fails the step with Slack’s error code on stderr — a refused post is never a silent success, because a plan must not report a message it didn’t deliver. That’s a tool error (exit 1), not an exit-gate assertion (exit 4). Common codes: not_in_channel (invite the bot), channel_not_found (wrong ID, or a private channel the app can’t see), missing_scope (add chat:write), invalid_auth (bad or revoked token), ratelimited.
permalink comes from a best-effort second call: a message that posted but whose permalink didn’t resolve comes back null rather than failing a delivery that already happened. The key is always present, so {{Ex.permalink}} never dangles.
The message reaches the API as a jq-built JSON payload, so quotes, newlines, and braces in a rendered template can’t break the request, and the token travels in a curl config on stdin rather than on the command line where the process table would expose it.
Block Kit is deliberately out of scope: text with mrkdwn is what a report needs, and a blocks array is a much larger surface to get right (its errors arrive as an opaque invalid_blocks). To send blocks, copy this tool’s YAML into a tools directory and add the field to your user__ copy.
Every field is always present, so gates and templates can consume the result unconditionally — iterate matches in a map body with {{item.path}}/{{item.line}}/{{item.text}}.