A per-user daemon in front of the GitHub API, so agents that loop on gh
stop burning the shared GraphQL budget.
▶ Play the interactive demo: a real recorded session against GitHub, with chapters and the numbers it measured.
Every gh pr view, gh pr checks and gh api graphql call spends points
from one 5,000-point/hour GraphQL budget per user. A handful of agents
polling PRs in loops drain it, and then everyone sees
GraphQL: API rate limit already exceeded for user ID ….
gh |
ghd |
|
|---|---|---|
| Command | gh pr view 2138 --json state,title |
ghd pr view 2138 --json state,title |
| Output | gh's JSON | the same fields and values |
| Fresh read latency | ~480 ms | ~5 ms |
| 10 concurrent reads (5 PRs × 2), cold | ~10 points | 2 points |
| The same 10 reads, warm | ~10 points | 0 points |
Numbers come from the demo and the spike. In a live check on 2026-10-25, one
ghd api graphql call went upstream, and 5 parallel identical calls after
it were all served fresh.
# Run straight from git (pin a commit SHA for reproducibility):
uvx --from git+https://github.com/forge-ci/ghd@<sha> ghd --help
# Or install it as a tool:
uv tool install git+https://github.com/forge-ci/ghd$ ghd pr view 2138 -R talos-hq/clud --json number,state,title,mergedAt
{
"mergedAt": "2026-10-24T21:12:09Z",
"number": 2138,
"state": "MERGED",
"title": "fix(gc): retry a locked registry …"
}The daemon starts itself on first use; there is nothing to configure.
PyPI: the
ghdname on PyPI is only a 0.0.0 reservation today.pip install ghdwill work with the first real release.
flowchart LR
A[agents / scripts] -->|ghd ...| C[ghd CLI]
C -->|loopback HTTP + protobuf + bearer token| D[ghd daemon]
D --> F{fresh? <2 s}
F -->|yes: 0 points| C
F -->|no| E[REST ETag check]
E -->|304: free| C
E -->|changed| B[batched GraphQL<br/>one query per repo per 50 ms]
B --> G[(api.github.com)]
A pr view is answered by the cheapest step that can prove the data fresh:
| step | cost | when |
|---|---|---|
fresh |
0 | validated in the last 2 s (GHD_FRESH_MS); concurrent identical reads wait on one per-PR lock |
revalidated |
0 | the PR's REST ETag returns 304 Not Modified, which GitHub does not count against the rate limit |
fetched |
~1 GraphQL point per batch | every PR of one repo requested within 50 ms goes into one aliased GraphQL query |
ghd api graphql uses the same idea for arbitrary queries: identical
concurrent queries share one upstream call, and an answer is reused for the
fresh window, which is exactly what an agent's re-check loop hits.
ghd gh <gh args...> a drop-in gh: native when ghd
understands it, else the real gh
ghd pr view <number> [-R owner/repo] --json f1,f2 like `gh pr view --json`
ghd api graphql -f query=... [-f k=v] [-F k=v] like `gh api graphql`
ghd stats what the daemon saved
ghd daemon run the daemon (auto-started)
ghd --version | --help
ghd gh (ghd#8)
- Takes any
ghcommand line. Shapes ghd serves natively (pr view N [-R X] --json F,api graphql -f/-F) go through the daemon. Everything else, including--jq/--templateand a failed nativepr view, runs the real gh with your stdin, stdout, stderr and terminal, and returns its exit code. The output and errors are gh's own. - The real gh is
$GHD_REAL_GHwhen set (a launcher with aghshim on PATH sets it), else the firstghon PATH that is not ghd. A relay that leads back into ghd fails with one line instead of looping. - Relayed calls are not cached yet; caching captured reads is the next step.
ghd pr view
- Same output as
gh pr view --json. Without-R, the repository comes from theoriginremote, as withgh. - Supported
--jsonfields so far are the plain scalars:additions,baseRefName,body,changedFiles,closed,closedAt,createdAt,deletions,headRefName,headRefOid,id,isDraft,mergeStateStatus,mergeable,mergedAt,number,state,title,updatedAtandurl.
ghd api graphql
- Takes gh's
-f(string) and-F(typed,@file) fields and prints GitHub's body. - Queries are deduplicated and cached for the fresh window.
- Mutations always go upstream and clear the query cache, so no read returns data from before a write.
- A body with
errorsis printed and the command exits 1, as with gh.
ghd stats prints the daemon's counters as JSON: reads,
served_fresh, sentinel_304_free, sentinel_200, graphql_queries,
graphql_prs_fetched, graphql_points_spent, api_graphql_upstream,
api_graphql_served_fresh, graphql_remaining, core_remaining and
uptime_secs.
ghd daemon runs the daemon in the foreground. You rarely need it: one
start lock means concurrent cold clients start exactly one daemon.
| variable | effect |
|---|---|
GHD_FRESH_MS |
fresh window in milliseconds (default 2000), read once at daemon start |
GHD_DEBUG |
when set, prints which step (fresh, revalidated, fetched) answered each read |
GHD_EXE |
the ghd executable used to start the daemon, if not the current one |
GH_TOKEN / GITHUB_TOKEN |
the token the daemon uses; falls back to gh auth token |
The point of ghd is to sit under tools that call GitHub in loops. A real
consumer: obs-rust/obs-studio
routes every GraphQL call through
uv run --no-project --script ci/gh_graphql.py ...which wraps ghd, and its agent PreToolUse hook bans bare
gh api graphql, so no agent session can bypass the cache by accident.
The same pattern works anywhere:
- Pin
ghdwithuvx --from git+https://github.com/forge-ci/ghd@<sha> ghd. - Point your scripts'
gh pr view --jsonandgh api graphqlcalls at it. - Add a hook or lint rule that rejects the bare
ghforms. - Watch
ghd statsto see what was saved.
Python callers can also use the bundled extension:
from ghd.bindings import pr_view
pr_view("talos-hq/clud", 2138, ["state", "mergeable"])| crate | role |
|---|---|
crates/ghd |
the public amalgam: pub use re-exports only; feature daemon |
crates/ghd-cli |
the ghd binary (bundled into the wheel by soldr) |
crates/ghd-py |
the PyO3 _native extension (client side only) |
crates/private/ghd-core |
wire protocol, endpoint file, loopback client, gh-compatible CLI |
crates/private/ghd-daemon |
loopback server, cache, sentinels, GraphQL batcher |
crates/private/ghd-platform |
the only crate with host-specific code (cfg_select! once) |
- The CLI client is std-only; it talks to the daemon over loopback HTTP with protobuf bodies and a bearer token.
- The daemon is built on kernal-api (async engine, HTTP client and server, JSON, secure random). No crate names tokio, reqwest or hyper directly.
- Every third-party dependency is pinned with
=. - More: DESIGN.md (including the roadmap: checks, lists, issues, writes and watches), docs/ARCHITECTURE.md and crates/README.md.
./test # soldr cargo test --workspace, build + install the wheel, pytest
./lint # rustfmt, clippy -D warnings, ruff
uv run --no-project --script ci/local.py precheck # ci-lint precheck (also a hook)CI is declared, not scripted: ci.toml (profile
rust-pypi-app) is the contract, checked by
ci-lint. Repo rules, gates and hooks
are in CLAUDE.md.
Merge a commit to main that bumps [workspace.package].version in
Cargo.toml and version in pyproject.toml. CI then builds every
platform's wheel, verifies them, tags the commit and publishes to PyPI with
no stored secrets. See RELEASE.md.
