Thingie is an opinionated but helpful and flexible AI code review tool for Ruby and Rails projects. It is heavily inspired by Gito and brings the same LLM-driven review workflow to Ruby codebases, with extra context pulled from the Ruby LSP.
- AI-powered code review via any OpenAI-compatible LLM (powered by ruby_llm).
- Ruby and Rails-aware review prompts out of the box.
- Reads project skills and rules from configurable directories (defaults to
.agents,.claude, and.cursor). - Configurable request timeouts, retries, and logging to fail fast on unreachable providers.
- Pulls extra context from language servers (LSP) during review — ruby-lsp first, any LSP via config. (Run static analyzers like RuboCop as a separate step.)
- Cuts false positives with a critic pass (
[verify]): every surviving finding is re-checked by a fresh, skeptical LLM call before it's reported. - Runs as a GitHub Action composite action, posting feedback as precise PR comments and (with a suitable token) resolving stale review threads automatically.
Add to your repository:
# .github/workflows/thingie-review.yml
name: "Thingie Review"
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Bonusly/rubyrt/.github/actions/thingie@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
provider: openrouter
model: moonshotai/kimi-k2.6ruby_version— Ruby the action installs to run Thingie. Defaults to3.4.thingie_command— how the CLI is invoked. Defaults tothingie. Set it tobundle exec thingie,direnv exec . thingie, etc. when your environment needs it.thingie_version— gem version to install (localbuilds from the checked-out repo). Set toskipto install nothing whenthingie_commandalready provides Thingie (e.g. it's in your Gemfile).
- uses: Bonusly/rubyrt/.github/actions/thingie@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
ruby_version: "4.0"
thingie_command: bundle exec thingie
thingie_version: skip # thingie comes from the project's GemfileThingie reviews its own pull requests. Below is the actual
.github/workflows/thingie-review.yml from this repo — copy it as a known-good
starting point. It mints a GitHub App token when one is configured (so comments
post under the app's name) and otherwise falls back to the default token.
# .github/workflows/thingie-review.yml
name: "Thingie Review"
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: "Pull Request number"
required: true
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
# Mint an app installation token so review comments post under the app's
# name/avatar. Skipped (falls back to GITHUB_TOKEN) when the app isn't set up.
- uses: actions/create-github-app-token@v3
id: app_token
if: ${{ vars.THINGIE_APP_ID != '' }}
with:
client-id: ${{ vars.THINGIE_APP_ID }}
private-key: ${{ secrets.THINGIE_APP_PRIVATE_KEY }}
- name: Run Thingie review
uses: Bonusly/rubyrt/.github/actions/thingie@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
model: ${{ vars.LLM_MODEL }}
provider: ${{ vars.LLM_PROVIDER }}
# Post as the app when configured, otherwise as github-actions.
github_token: ${{ steps.app_token.outputs.token || github.token }}
# PAT (user token) with pull-requests write so stale review threads can
# be auto-resolved — GITHUB_TOKEN and App tokens cannot. Skipped if unset.
resolve_token: ${{ secrets.THINGIE_RESOLVE_TOKEN }}Model and provider come from repo variables (vars.LLM_MODEL,
vars.LLM_PROVIDER) so you can change them without editing the workflow. This
repo runs the review on OpenRouter and re-checks findings with a stronger model
in the critic pass — see The critic pass.
The action referenced above is the composite action at
.github/actions/thingie/action.yml; it
installs Ruby, installs Thingie, computes the base ref (unshallowing as needed so
merge-base is correct), runs the review, posts the comment, and uploads the JSON
and Markdown reports as artifacts.
secrets.LLM_API_KEY: Your LLM provider API key.secrets.GITHUB_TOKENis provided automatically by GitHub Actions. It must have at leastpull-requests: writepermission (set in the workflowpermissionsblock) so Thingie can post review comments.- Want comments to post under a custom name and avatar instead of "github-actions"? See Posting as a GitHub App below.
- Resolving stale review threads needs an extra token — see Resolving stale review threads below. The default
GITHUB_TOKENcannot do it.
By default Thingie posts as github-actions[bot] (the workflow GITHUB_TOKEN). To post under your own bot name and avatar, authenticate with a GitHub App installation token and pass it as the action's github_token input — review comments then appear as YourApp[bot].
- Create the app: Settings → Developer settings → GitHub Apps → New GitHub App (under your org or account). Give it the name and avatar you want to see on comments.
- Repository permissions → Pull requests: Read and write. (No webhook needed — uncheck Active.)
- Generate a private key and note the App ID.
- Install the app on the repositories that run Thingie.
- Store the App ID as a variable (e.g.
vars.THINGIE_APP_ID) and the private key as a secret (e.g.secrets.THINGIE_APP_PRIVATE_KEY). - Mint an installation token with
actions/create-github-app-tokenand pass it asgithub_token:
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/create-github-app-token@v2
id: app_token
with:
app-id: ${{ vars.THINGIE_APP_ID }}
private-key: ${{ secrets.THINGIE_APP_PRIVATE_KEY }}
- uses: Bonusly/rubyrt/.github/actions/thingie@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
github_token: ${{ steps.app_token.outputs.token }} # post as the app
resolve_token: ${{ secrets.THINGIE_RESOLVE_TOKEN }} # resolve threads (PAT — see below)The app installation token posts comments fine, but cannot resolve review threads (
resolveReviewThreadrejects server-to-server tokens). If you also want stale threads auto-resolved, pair it with a user PAT inresolve_tokenas shown — see the next section.
When Thingie re-reviews a PR, it can mark its earlier threads as resolved once the issue is fixed or the line is outdated. This uses the GraphQL resolveReviewThread mutation, which requires a user-to-server token (a personal access token).
The default
GITHUB_TOKENcannot do this, and neither can a GitHub App. Both are server-to-server tokens, andresolveReviewThreadreturnsResource not accessible by integrationfor them even withpull-requests: write— including the installation token fromactions/create-github-app-token. Only a user PAT works.
To enable auto-resolving, create a PAT and pass it via the resolve_token action input (or the THINGIE_RESOLVE_TOKEN env var / --resolve-token flag for the github-comment CLI). If you don't set one, Thingie skips resolving and still posts the new review.
- Create a token as a user with write access to the repo:
- Classic PAT with the
reposcope (recommended — reliably works forresolveReviewThread). Settings → Developer settings → Personal access tokens → Tokens (classic). If your org enforces SSO, click Configure SSO on the token and authorize it for the org. - Fine-grained PATs are not recommended here. Even with Pull requests: Read and write they often fail
resolveReviewThreadwithResource not accessible by personal access token— and for an org repo the write permission stays read-only until an org owner approves it, so the fetch succeeds but resolving fails. If you must use one, get it org-approved and expect to troubleshoot.
- Classic PAT with the
- Store it as a repository (or org) secret, e.g.
secrets.THINGIE_RESOLVE_TOKEN. - Pass it to the action:
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Bonusly/rubyrt/.github/actions/thingie@v1
with:
api_key: ${{ secrets.LLM_API_KEY }}
provider: openrouter
model: moonshotai/kimi-k2.6
resolve_token: ${{ secrets.THINGIE_RESOLVE_TOKEN }}Threads are resolved as whichever user owns the PAT. (A machine/bot user account is a good fit if you don't want resolutions attributed to a person.)
Thingie can submit an Approve review when a PR is clean enough. It's off by default — enable it under [approve] in .thingie/config.toml:
[approve]
enabled = true
max_changes = 500 # additions + deletions ceiling; blocks approval if GitHub doesn't report the size
max_severity = 3 # BLOCK line: issues at/below this severity number (1=Critical) block approval — independent from post_process.max_severity (the SHOW line), see "Filtering findings" below
skip_label = "thingie-skip-approve"
approval_team = "bonusly/pr-auto-approval" # only auto-approve PRs whose author is on this GitHub team (needs read:org)
protected_paths = ["app/billing/**", "config/secrets.yml"] # globs that block approval when changed
dry_run = false # evaluate and log the decision without approving/dismissingA PR is approved only when all of these hold:
- It isn't a draft and doesn't carry the
skip_label. - Its author is a member of
approve.approval_team, when that option is set (a non-member is skipped, not blocked, so a human's approval is left intact). Reading team membership needs aread:orgtoken — setTHINGIE_RESOLVE_TOKENto a PAT with that scope. - It doesn't modify
.thingie/config.toml— a PR can't weaken the approval rules and wave itself through. - No changed file matches a
protected_pathsglob — e.g. billing code or sensitive config you always want a human to review. - Total changes are within
max_changes(an unknown size fails safe and blocks approval). - This run produced no findings at or above
max_severity. - No Thingie findings at or above
max_severityare still unresolved. - No human reviewer's current review is
CHANGES_REQUESTED(a later dismissal or approval by that reviewer clears it). Thingie never waves through a change a human has pushed back on; an undeterminable review state fails safe and blocks. - Every resolved Thingie finding at or above
max_severitywas resolved by someone who is neither the PR author nor a contributor to the PR — an author can't clear their own findings to earn an approval.
When Thingie does not approve — a rule failed (block) or the run was skipped — it posts a single status comment on the PR explaining why, and keeps that comment updated in place on re-runs (it doesn't stack a new comment each push). Once the PR qualifies and is approved, that status comment is removed.
Re-runs are idempotent: Thingie won't stack a second approval on a head commit it already approved, and it dismisses its earlier approval if a later push stops meeting the rules. It also dismisses any approval left over from a previous commit before approving a new head, so an approval never outlives the exact commit it was granted for. With dry_run = true it still posts the status comment (marked as a dry run) and logs the decision, but takes no approve/dismiss action — useful for rollout.
Close the re-review window with branch protection. Thingie only runs as the push-triggered workflow, so between a new push and the re-review finishing, an approval granted for the previous commit still counts unless GitHub dismisses it. Enable branch protection's "Dismiss stale pull request approvals when new commits are pushed" so a new commit invalidates the prior approval instantly; Thingie then re-approves only if the new commit still passes.
The approval comment lists the checks that passed and a collapsed Thingie details section (Thingie version and the review model). If you set approve.external_checks (e.g. ["Security review pass", "Full test suite"]), those are listed under Other checks that must pass before merge — checks Thingie does not evaluate but that still gate merge, so it's clear Thingie's approval isn't the only gate (they don't affect Thingie's decision).
When an LLM key is available in the github-comment step, it also adds an informational risk assessment. Rather than restating the rules, the LLM looks at the actual code diff and gives an honest Low/Medium risk read focused on regressions, downtime, and security impact, with a short reason that justifies the approval. The risk level never affects the decision — the deterministic rules above do — and the section is simply omitted if no LLM client is configured or the call fails.
The approval needs the workflow's pull-requests: write permission (already required for comments). The risk assessment additionally needs an LLM key (LLM_API_KEY) available to the github-comment step; without it the approval still posts, just without that section. Thingie tries the approval with the main github_token first, then falls back to the resolve_token PAT if that attempt fails.
Heads up on which token approves. An approval submitted by
GITHUB_TOKEN(or a GitHub App installation token) does not count toward branch-protection "required approvals", and the fallback only triggers when the first attempt errors — a non-counting approval fromGITHUB_TOKENsucceeds and won't fall through to the PAT. If you need the approval to satisfy required reviewers, run Thingie with the PAT as its maingithub_token(or as a bot user) so the counting identity is the one that approves.
Limitation. "Contributor" is determined from commit author/committer GitHub logins, so a
Co-authored-by:trailer doesn't count as having committed. If a thread's resolver can't be determined (e.g. a deleted account), Thingie fails safe and does not approve.
Thingie reads configuration from layers (later layers override earlier ones):
lib/thingie/config/default.tomlbundled with the gem..thingie/config.tomlin your project root.~/.thingie/.env(loaded into your environment).- Environment variables.
- CLI flags.
Key settings:
| Setting | Default | Config key | Environment variable | CLI flag |
|---|---|---|---|---|
| LLM provider | openai |
provider |
LLM_PROVIDER |
-p, --provider |
| LLM model | gpt-4o |
model |
LLM_MODEL |
-m, --model |
| API key | — | llm_api_key |
LLM_API_KEY |
— |
| API base URL | provider default | llm_api_base |
LLM_API_BASE |
— |
| Request timeout (seconds) | 120 |
request_timeout |
LLM_REQUEST_TIMEOUT |
— |
| Retries | 3 |
retries |
LLM_RETRIES |
— |
| Log file | stdout | log_file |
THINGIE_LOG_FILE |
— |
| Log level | info |
log_level |
THINGIE_LOG_LEVEL |
— |
| Max concurrent file reviews | 10 |
max_concurrent_tasks |
MAX_CONCURRENT_TASKS |
— |
| Confidence threshold (keep if ≤) | 1 |
post_process.max_confidence |
— | — |
| Severity threshold (keep if ≤) | 3 |
post_process.max_severity |
— | — |
| Critic pass enabled | true |
verify.enabled |
— | — |
| Critic pass model | review model | verify.model |
— | — |
| Auto-approve PRs | false |
approve.enabled |
— | — |
| Auto-approve change limit | 500 |
approve.max_changes |
— | — |
| Auto-approve severity gate | 3 |
approve.max_severity |
— | — |
| Auto-approve skip label | thingie-skip-approve |
approve.skip_label |
— | — |
| Auto-approve protected paths | [".thingie/**/*"] |
approve.protected_paths |
— | — |
| Auto-approve team gate | "" (disabled) |
approve.approval_team |
— | — |
| Auto-approve dry run | false |
approve.dry_run |
— | — |
| Skill directories | .agents, .claude, .cursor |
skill_directories |
— | — |
| Language servers | none | lsp.<name> |
— | — |
Supported providers match whatever RubyLLM supports, including openai, anthropic, gemini, ollama, deepseek, openrouter, mistral, perplexity, xai, azure, bedrock, vertexai, and gpustack.
Example .thingie/config.toml:
provider = "openrouter"
model = "moonshotai/kimi-k2.6"
request_timeout = 60
log_file = "log/thingie.log"
log_level = "debug"
# Customize which directories are scanned for skill fragments
skill_directories = [".agents", ".github"]
# Only keep the most-confident findings (1) up to medium severity (3).
[post_process]
max_confidence = 1
max_severity = 3
# Re-check every surviving finding with a stronger model before reporting it.
[verify]
enabled = true
model = "anthropic/claude-opus-4.8"
# Give the LLM access to a language server for extra code context.
[lsp.ruby]
command = ["ruby-lsp"]
extensions = [".rb", ".rake"]Thingie automatically discovers markdown files (.md) in skill directories and injects them as additional rules in every review prompt. By default it scans .agents/, .claude/, and .cursor/ in the project root. Override this with the skill_directories config key.
The biggest source of noise in AI review is confident-but-wrong findings — claims that simply aren't true about the code. Tightening the confidence and severity thresholds only filters by the model's self-reported confidence, which it routinely over-states.
The [verify] critic pass attacks this directly. After the normal review and
threshold filtering, each surviving finding gets a second, fresh LLM call
framed adversarially: "try to refute this; uphold it only if you can point to
the lines that make it true; when uncertain, reject." The critic has the diff,
the full file, and (if configured) the LSP symbol tool, so it can confirm API
and method claims instead of trusting them. Findings it can't uphold are
dropped.
[verify]
enabled = true # on by default; set false to skip the pass
model = "anthropic/claude-opus-4.8" # optional: re-check on a stronger modelverify.enabled— turn the critic pass on or off. Defaulttrue.verify.model— run the critic on a different (usually stronger) model than the review, using the same provider and API key. Leave it unset to reuse the review model. With OpenRouter the model slug is provider-prefixed (e.g.anthropic/claude-opus-4.8), so one key covers both the cheap review model and the strong critic.
Because the critic runs only on findings that survived filtering — not on every file — its cost scales with the number of findings, not the size of the diff. It also fails open: if a critic call errors or returns an unparseable verdict, the finding is kept and a processing warning is recorded, so a broken critic never silently swallows a real bug.
The critic can also correct a miscalibrated severity or confidence grade
instead of only upholding/rejecting a finding outright — e.g. downgrade an
overstated "Critical" cosmetic nit, or upgrade an understated security issue.
Both the review and critic prompts are given the same severity/confidence
scale and the current post_process/approve thresholds (see below), so the
model reasons about real consequences rather than abstract labels. An override
only affects this run's final severity/confidence (and therefore whether the
finding blocks auto-approval, below) — it does not re-run the post_process
filter that already let the finding through to the critic in the first place.
The model scores every finding on a 1–4 severity scale (1 = Critical) and a
1–4 confidence scale (1 = highest). post_process is the SHOW line: it
drops anything above your thresholds before the critic pass runs, controlling
whether a finding is surfaced to maintainers as a PR comment at all:
[post_process]
max_confidence = 1 # keep only the model's highest-confidence findings
max_severity = 3 # keep Critical/High/Medium, drop LowLower numbers are stricter. An omitted threshold means "no limit". This is a cheap first filter; the critic pass is the precision filter on top of it.
This is independent from approve.max_severity (the BLOCK line, see
Auto-approving PRs) — a finding can be shown as a
comment without blocking auto-approval, e.g. post_process.max_severity = 3
but approve.max_severity = 2.
When you configure a language server, Thingie exposes a symbol-lookup tool to the LLM during both review and verification. The model uses it to confirm that a class, method, or constant actually exists (and how a third-party API is shaped) before reporting an "undefined" or "misused API" issue — a major source of hallucinated findings.
# Each [lsp.<name>] entry is launched in the reviewed project's root, so use
# that project's own LSP binary. Add more languages with more entries.
[lsp.ruby]
command = ["ruby-lsp"]
extensions = [".rb", ".rake"]A server is only started when the changeset contains a file whose extension
matches one of its extensions, so unrelated reviews don't pay the startup
cost. LSP is disabled entirely when no [lsp.*] entry is configured.
Set log_file to a file path to persist RubyLLM request logs for debugging. Set log_level to debug to see full request and response bodies. Valid levels are debug, info, warn, error, and fatal.
Thingie uses structured output to ensure the LLM returns valid JSON. Not all models support this feature; see the RubyLLM available models page for a list of compatible models. For models that don't support structured output, Thingie falls back to parsing the response as JSON from the prompt instructions.
bundle install
bundle exec rakeMIT. See LICENSE.txt.