> ## Documentation Index
> Fetch the complete documentation index at: https://graph.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Built-ins

> Tool packs that ship with Graph

Built-in tools are [user-tool-format](/tools/user-defined) YAML documents compiled into the graph binary and served under the `builtin__` namespace. Same schema, same validation, same catalog — the namespace just says who ships them. They're grouped into **packs**, enabled per pack:

```toml theme={null}
[tools]
packs = ["github", "slack"]
```

The `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 mostly `exec` 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](/getting-started/installation#container-image).

## The `llm` pack (always on)

### `builtin__infer`

Generic LLM inference — the [`prompt` tool kind](/tools/user-defined) 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.

| Input           | Type   |                                                                                                                                                                                                                                                                                         |
| --------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instruction`   | string | required — complete, self-contained instruction; interpolate the data to analyze with `{{Ex...}}` templates                                                                                                                                                                             |
| `output_schema` | object | optional — an object JSON Schema for the result (top-level `"type": "object"` plus a `properties` map); omit for `{ "text": ... }`                                                                                                                                                      |
| `model`         | string | optional — a [named model](/models/models-and-providers#named-models) (or role name) to run on; omit for the chat default. When named models are configured, the catalog advertises them on this input with their descriptions, steering the planner toward the smallest adequate model |

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](/plans/iteration#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](/tools/user-defined#reshape--project-data-into-a-new-shape) 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.

| Input   | Type   |                                                                                                    |
| ------- | ------ | -------------------------------------------------------------------------------------------------- |
| `shape` | object | required — the output shape; keys are literal, each leaf string is a template over the step's data |

The `shape` is a JSON object whose keys are literal and whose leaf strings are [templates](/plans/template-language) over the surrounding step's data — an earlier result as `{{Ex.field}}`, the current [`map`](/plans/iteration) 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](/plans/template-language#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.

```yaml theme={null}
- id: E2
  tool_name: builtin__reshape
  input:
    shape:
      base_sha: "{{E1.baseRefOid}}"   # rename a key from an earlier result
      pr: "{{E1.number}}"             # keep the number type
      title: "PR #{{E1.number}}"      # interpolate a string
```

It **only moves data** — rename, pick, nest, flatten, interpolate. It is logic-less by construction (the [template dialect](/plans/template-language) has no computation), so it can't derive values (sums, casing, conditionals); wrap those in a `user__` [`exec` tool](/tools/user-defined). 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`](/plans/agent-step) 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](/plans/iteration) 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](/cookbook/ci-checks) 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.

| Input | Type    |                                |
| ----- | ------- | ------------------------------ |
| `pr`  | integer | required — pull request number |

| Output     | Type   |                                  |
| ---------- | ------ | -------------------------------- |
| `title`    | string | PR title                         |
| `body`     | string | PR description (`""` when empty) |
| `base_sha` | string | tip of the base branch           |
| `head_sha` | string | tip of the PR branch             |
| `state`    | string | `OPEN`, `MERGED`, …              |
| `author`   | string | login                            |

### `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`).

| Input    | Type    |                                                                                                                                                      |
| -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pr`     | integer | required                                                                                                                                             |
| `body`   | string  | required — markdown comment body (render it with [template sections](/plans/template-language#sections))                                             |
| `marker` | string  | optional — stable identifier for find-and-edit; use one per plan (e.g. `"graph:pr_review:summary"`). Omit to edit the identity's most recent comment |

Output: `{ "text": "<comment URL>" }`.

<Warning>
  When two plans post summary comments to the same PR under one token, the markerless `--edit-last` upsert makes the later run overwrite the earlier plan's comment. Give each plan its own `marker` to keep the comments separate.
</Warning>

### `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.

| Input     | Type    |                                                                                                                                                   |
| --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pr`      | integer | required                                                                                                                                          |
| `pattern` | string  | optional — POSIX extended regex the ID must match; default `\b[A-Za-z]{2,6}[- ][0-9]+\b` covers common tracker forms like `LN-1234` and `LN 1234` |

| Output   | Type           |                                                     |
| -------- | -------------- | --------------------------------------------------- |
| `found`  | boolean        | ready for `exit`/`decide` gates                     |
| `ticket` | string \| null | normalized ID; `null` when not found                |
| `source` | string \| null | `branch`, `title`, or `body`; `null` when not found |

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`](#builtin-git-log) or [`git_diff`](#builtin-git-diff) over the range that shipped. Omit `tag` for the latest release. Read-only.

| Input                 | Type    |                                                                                                                                    |
| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `tag`                 | string  | optional — release tag (e.g. `"v1.4.0"`); default `""` for the latest release                                                      |
| `include_prereleases` | boolean | optional — whether prereleases count when resolving `previous_tag`; default `false`. Never affects which release `tag` resolves to |

| Output          | Type           |                                                                                                           |
| --------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
| `tag`           | string         | the release's tag                                                                                         |
| `name`          | string         | release title (`""` when untitled)                                                                        |
| `body`          | string         | release notes markdown — GitHub's generated notes when that is how the release was made (`""` when empty) |
| `url`           | string         | release page                                                                                              |
| `published_at`  | string \| null | ISO 8601; `null` for a draft                                                                              |
| `author`        | string \| null | login                                                                                                     |
| `is_prerelease` | boolean        |                                                                                                           |
| `is_draft`      | boolean        |                                                                                                           |
| `target`        | string \| null | target commitish the tag was cut from                                                                     |
| `previous_tag`  | string \| null | tag of the release before this one; `null` on the first release                                           |

`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 —

```yaml theme={null}
- id: E1
  tool_name: exit
  input:
    when: { value: "{{E0.previous_tag}}", op: empty }
    status: success
    message: first release — nothing to compare against
```

### `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`).

| Input      | Type    |                                                                                                |
| ---------- | ------- | ---------------------------------------------------------------------------------------------- |
| `pr`       | integer | required                                                                                       |
| `head_sha` | string  | required — commit the comments anchor to (the PR head)                                         |
| `marker`   | string  | required — stable identifier for refresh-in-place; use one per plan (e.g. `"graph:pr_review"`) |
| `comments` | array   | required — entries of `{path, line?, side?, body}`; extra keys pass through                    |

| Entry key | Type    |                                                                    |
| --------- | ------- | ------------------------------------------------------------------ |
| `path`    | string  | required — file path within the diff                               |
| `line`    | integer | line in the new file; must be part of the diff — omit when unknown |
| `side`    | string  | `LEFT` or `RIGHT` (default `RIGHT`)                                |
| `body`    | string  | required — markdown comment body                                   |

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`](#builtin-gh_pr_thread_sync) to post and transition, [`gh_pr_review_threads`](#builtin-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`](#builtin-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.

| Input    | Type    |                                                                 |
| -------- | ------- | --------------------------------------------------------------- |
| `pr`     | integer | required                                                        |
| `marker` | string  | required — the payload tag prefix the findings were posted with |

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`](#builtin-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`).

| Input          | Type    |                                                                                                                                   |
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `pr`           | integer | required                                                                                                                          |
| `head_sha`     | string  | required — commit new comments anchor to (the PR head)                                                                            |
| `marker`       | string  | required — payload tag prefix; use one per plan (e.g. `"graph:pr_review:finding"`)                                                |
| `creates`      | array   | required — new findings: `{path, line?, side?, body, payload}`; extra keys pass through                                           |
| `threads`      | array   | required — existing findings: `{thread_id, transition, note?}`; extra keys pass through                                           |
| `sweep_marker` | string  | optional — a legacy delete-and-repost marker whose comments are deleted first; a one-time migration aid, omit in the steady state |

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).

| Input       | Type    |                                                            |
| ----------- | ------- | ---------------------------------------------------------- |
| `base`      | string  | required — ref or sha                                      |
| `head`      | string  | required — ref or sha                                      |
| `paths`     | string  | required — space-separated pathspecs; `"."` for everything |
| `exclude`   | string  | required — space-separated excluded paths; `""` for none   |
| `max_bytes` | integer | required — truncation budget                               |

Output: `{ "text": "<diff>" }` — empty string when nothing changed, which pairs with an `empty` [exit gate](/plans/exit-gates).

### `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`](#builtin-git-diff) takes. Pair it with [`gh_release`](#builtin-gh-release)'s `previous_tag` as `base` and the release tag as `head`.

| Input            | Type    |                                                                 |
| ---------------- | ------- | --------------------------------------------------------------- |
| `base`           | string  | required — ref the range starts *after* (exclusive)             |
| `head`           | string  | optional — ref the range ends at (inclusive); default `HEAD`    |
| `paths`          | string  | optional — space-separated pathspecs; default `""` (everything) |
| `max_commits`    | integer | optional — cap on returned commits; default 200                 |
| `include_merges` | boolean | optional — keep merge commits; default `false`                  |

| Output      | Type    |                                                                     |
| ----------- | ------- | ------------------------------------------------------------------- |
| `commits`   | array   | `{sha, short_sha, author, date, subject, pr}` objects, newest first |
| `count`     | integer | commits returned, ready for `eq`/`gt` gates                         |
| `total`     | integer | commits in the whole range, before `max_commits`                    |
| `truncated` | boolean | `true` when `max_commits` cut the list                              |

`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.

| Input    | Type   |                                                        |
| -------- | ------ | ------------------------------------------------------ |
| `base`   | string | required                                               |
| `head`   | string | required                                               |
| `prefix` | string | required — path prefix filter; `""` matches every file |

| Output    | Type            |                                                                       |
| --------- | --------------- | --------------------------------------------------------------------- |
| `files`   | array of string | matching paths                                                        |
| `count`   | integer         | `files.length`, ready for `eq`/`gt` gates                             |
| `changes` | array of object | one `{path, status, old_path, additions, deletions, binary}` per file |

`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`](#builtin-git_file) at the head sha fails on it by design. Select before you read: [`filter`](/plans/selection) 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.

| Input       | Type    |                                                |
| ----------- | ------- | ---------------------------------------------- |
| `ref`       | string  | required — ref or sha (e.g. the PR `head_sha`) |
| `path`      | string  | required — file path within the repository     |
| `max_bytes` | integer | optional — truncation budget; default 200000   |

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.

| Input         | Type    |                                                                 |
| ------------- | ------- | --------------------------------------------------------------- |
| `pattern`     | string  | required — POSIX extended regex                                 |
| `ref`         | string  | optional — ref or sha to search at; default `HEAD`              |
| `paths`       | string  | optional — space-separated pathspecs; default `""` (everything) |
| `max_matches` | integer | optional — cap on returned matches; default 200                 |

| Output      | Type    |                                                   |
| ----------- | ------- | ------------------------------------------------- |
| `matches`   | array   | `{path, line, text}` objects, in git's path order |
| `count`     | integer | `matches.length`, ready for `eq`/`gt` gates       |
| `truncated` | boolean | `true` when `max_matches` cut the list            |

## 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**.

<Card title="Create the Graph CI Slack app" icon="slack" href="https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%3A%22Graph%20CI%22%7D%2C%22features%22%3A%7B%22bot_user%22%3A%7B%22display_name%22%3A%22Graph%20CI%22%2C%22always_online%22%3Afalse%7D%7D%2C%22oauth_config%22%3A%7B%22scopes%22%3A%7B%22bot%22%3A%5B%22chat%3Awrite%22%5D%7D%2C%22pkce_enabled%22%3Afalse%7D%2C%22settings%22%3A%7B%22interactivity%22%3A%7B%22is_enabled%22%3Atrue%7D%2C%22org_deploy_enabled%22%3Afalse%2C%22socket_mode_enabled%22%3Atrue%2C%22token_rotation_enabled%22%3Afalse%2C%22is_mcp_enabled%22%3Afalse%7D%7D">
  Pre-filled from [`app-manifest.json`](https://github.com/tylerdavis/graph/blob/main/crates/graph-core/src/packs/slack/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.
</Card>

### `builtin__slack_post_message`

Post a message to a channel ([`chat.postMessage`](https://api.slack.com/methods/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`).

| Input       | Type   |                                                                                                                                                                                      |
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `channel`   | string | required — channel ID (`C…`, from the channel's *Copy link*) or `#channel-name` for a public channel the bot has joined. IDs are the reliable form; names depend on lookup           |
| `text`      | string | required — message body in Slack [mrkdwn](https://api.slack.com/reference/surfaces/formatting) (`*bold*`, `_italic_`, `` `code` ``, `<https://url\|label>`), **not** GitHub markdown |
| `thread_ts` | string | optional — parent message `ts` to reply under; default `""` (a new top-level message)                                                                                                |

| Output      | Type           |                                                                     |
| ----------- | -------------- | ------------------------------------------------------------------- |
| `channel`   | string         | resolved channel ID — pass it back with `ts` to reply in-thread     |
| `ts`        | string         | message timestamp; the `thread_ts` for replies to this message      |
| `permalink` | string \| null | link to the message; `null` when the permalink lookup didn't answer |

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](/plans/exit-gates) 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.

<Warning>
  `text` is the message *and* the notification every channel member sees. Gate a noisy plan with an [exit gate](/plans/exit-gates) *before* the post rather than posting "nothing to report".
</Warning>

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](/plans/iteration) with `{{item.path}}`/`{{item.line}}`/`{{item.text}}`.


## Related topics

- [Changelog](/changelog.md)
- [Installation](/getting-started/installation.md)
- [User-defined tools](/tools/user-defined.md)
- [Configuration](/reference/configuration.md)
- [Selection](/plans/selection.md)
