Skip to main content
pinbox link turns a pin into a GitHub issue and keeps the two in sync from then on. Comments flow both directions. Closing either side closes the other. Use it when a pin needs to leave the loop — it is not a bug someone will fix in this session, and it belongs on the backlog where the rest of the team can see it.

Requirements

Pinbox has no GitHub dependency, no OAuth app, and no token of its own. It shells out to your gh:
If those work, pinbox works. Your credentials, your GitHub host, your enterprise config — all of it is gh’s, not ours.
gh runs with your project directory as its working directory, so the issue lands in whatever repository gh resolves there. Check with gh repo view if you are unsure. pinbox doctor reports the state:
All three say ok, and that is deliberate: the gh check is informational. It gates pinbox link and nothing else, so a missing gh never fails a doctor run. Read the detail, not the prefix.

On a cloud hub

A Worker cannot shell out, so the cloud hub uses a GitHub App instead of gh. Issues and comments are authored by your App’s bot account, access is scoped to the repositories you install it on, and every request uses a one-hour installation token minted from the App’s private key inside the Worker. One command does the whole setup, from the repo that holds your worker directory:
Your browser opens twice: once to review and create the App (GitHub builds it from a manifest pinbox supplies, so permissions, events and the webhook URL are already right), once to install it on the repo. pinbox then writes GITHUB_APP_ID, GITHUB_INSTALLATION_ID and GITHUB_REPO into the worker’s wrangler.jsonc, sets the private key and webhook secret with wrangler secret put, and verifies the App can read the repo. Deploy the worker and you are done. There is nothing to configure: the repo is this checkout’s origin, the worker is found by its config, the hub’s public URL is read from a custom_domain route in the worker’s wrangler.jsonc (on workers.dev it asks for it), and whether the App is created under an organization or a user follows the repo owner’s profile. If wrangler is not logged in, the two secrets are printed for you to set. The manual route, if you would rather click through GitHub:
  1. Create the App under the organization that owns the repo: GitHub → Settings → Developer settings → GitHub Apps → New. Permissions: Issues: Read & write and Metadata: Read-only. Webhook on: URL https://<worker-host>/_pinbox/webhooks/github, content type application/json, a secret you generate; subscribe to Issues and Issue comment.
  2. Generate a private key; GitHub downloads a .pem. Note the App ID.
  3. Install the App on that organization, selecting the repo. The installation id is the number at the end of the page you land on, …/settings/installations/12345678.
  4. In the Worker: GITHUB_APP_ID, GITHUB_INSTALLATION_ID and GITHUB_REPO (owner/name) as vars; the PEM as the GITHUB_APP_PRIVATE_KEY secret (accepted as downloaded, no conversion step) and the webhook secret as GITHUB_WEBHOOK_SECRET.
From then on pinbox link <id> github against that hub works exactly as it does locally. Inbound is immediate: a new issue comment or a close/reopen reaches the pin the moment GitHub delivers the webhook, and the hub’s alarm poll still replays the issue as a safety net, recognising anything the webhook already mirrored.
github is the default connector, so pinbox link pin_a1b2c3d4e5 github is the same command. With --json you get the full updated pin back, links included:
ref is the issue number. Links do not show up in the human pinbox show output — read them from pinbox show <id> --json.
Running pinbox link twice on the same pin creates a second issue. Nothing deduplicates for you — the conflict guard only fires on an identical connector and ref. Link a pin once.

What lands on GitHub

The issue title is the pin’s first line, truncated to 72 characters. The body is the pin rendered as markdown, with a trailer identifying it:
Mirrored comments carry a trailer too — — pinbox <message-id>. Those trailers are how pinbox recognizes its own writing and refuses to read it back in, so don’t strip them.

Two-way mirroring

Once a pin is linked, the pin thread and the issue conversation are the same conversation.

Pin → issue

Every new thread message posts as an issue comment. That includes your pinbox reply and anything the agent writes.

Issue → pin

Every new issue comment appears in the pin thread with the role mirror and an origin of github:<username>.
So a teammate who never opens your app can answer a pin from the GitHub UI, and the agent working the pin sees it in the thread:
Echo is impossible by construction, not by heuristics: a message tagged with a connector’s origin is never mirrored back to that connector, and pinbox skips its own trailer-marked comments on the remote side too. The one interesting case is cross-connector — a comment that arrived from one tracker does flow out to a different one.

Status sync

Status moves both ways, and the rules are symmetric: A status change caused by the other side is never bounced back — closing an issue resolves the pin, and that resolution does not then re-close the issue.

When sync happens

Mirroring reconciles on a poll, not a webhook. Nothing needs to be exposed to the internet.
  • Every 60 seconds while the pin is open.
  • Every 10 minutes once it is resolved.
  • Once at hub start, so a link that went stale while nothing was running catches up immediately.
The hub runs on demand and exits when idle (30 minutes by default). Any pinbox command starts it again, and boot reconciliation is the first thing it does — so if you want to force a sync right now, run any pinbox command. Each reconcile flushes what pinbox owes the issue, in timestamp order, behind a resumable cursor. If the network drops halfway through, the cursor advances only over comments that actually landed — a retry resumes rather than reposting. You will not get duplicate comments on your issue.

Errors

E_CONNECTOR exits with code 10. Whether pinbox can reach GitHub is decided at hub start: if gh is on PATH at that moment, the connector is available for the life of that hub. If you install gh while a hub is already running, let it idle out (or stop it) so the next one picks gh up. Per-pin sync failures never take down the run. A failing link logs to stderr and stays due, so the next poll retries it.

Other trackers

link takes a connector name because GitHub is not the only one. The connector interface is small — create an item, post a comment, read remote state, set remote status — and the anti-echo and status rules live in pinbox core rather than in each connector, so a new one inherits them. A Slack connector ships alongside GitHub and turns on when both SLACK_BOT_TOKEN and SLACK_CHANNEL are set in the hub’s environment. Asking for a connector that is not available answers with E_CONNECTOR and a pointer to pinbox doctor, which lists what this hub can actually reach.