Unlike the Slack, Discord, and WhatsApp ingresses — long-lived socket bridges that run inside the agent-server — the GitHub ingress is a Cloudflare Worker. It is webhook-first: GitHub delivers events over HTTPS, the Worker validates and queues them, and a queue consumer forwards each one to the shared SmolPaws runner (the agent-server) and posts the reply back on the issue or pull request.
Two things reach the Worker:
POST /webhooks/github, for repositories where the App is installed.* * * * *) that reads the account's notifications with a user token, to catch @smolpaws mentions on repos where the App is not installed.
flowchart LR
GH["GitHub\nissue / PR"] -->|"webhook\nPOST /webhooks/github"| W["Cloudflare Worker\nsmolpaws.liberty-labs.org"]
GH -.->|"notifications\n(cron poll)"| W
W -->|"validate + gate + dedup"| Q["Cloudflare Queue"]
Q --> C["Queue consumer"]
C -->|"buildConversationId()"| ID["github-<repo>-<number>"]
C -->|"HTTP turn"| AS["agent-server\n(SMOLPAWS_RUNNER_URL)"]
AS -->|"runs agent"| OH["OpenHands\nruntime"]
OH -->|"turn result +\noutbound messages"| AS
AS -->|"reply"| C
C -->|"POST issues/{n}/comments"| GH
This is the behavior worth documenting. Every event on the same GitHub issue or pull request maps to the same agent conversation, deterministically. There is no lookup table — the id is derived from the repo and the issue/PR number:
function buildConversationId(message): string {
const repo = (message.payload.repository?.full_name ?? 'repo')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const threadNumber =
message.payload.issue?.number
?? message.payload.pull_request?.number
?? 0;
return `github-${repo}-${threadNumber}`;
}
So a comment on OpenHands/software-agent-sdk#4299 always becomes conversation github-openhands-software-agent-sdk-4299. The practical effect: when you ping @smolpaws again in the same PR thread, it resumes the same conversation — it keeps the earlier context (the PR description it already read, the diff it already ran, the results it already found) instead of starting cold each time. In practice that reads as smarter follow-ups and fewer tokens spent re-deriving what it already knew.
GitHub is the natural home for this mapping because an issue/PR number is a stable thread identity. The socket bridges do the same conceptually (channel/thread → conversation id), but GitHub's is the cleanest: a URL already names the thread.
| GitHub thread | Conversation id |
|---|---|
owner/repo issue #42 | github-owner-repo-42 |
owner/repo PR #118 | github-owner-repo-118 |
The Worker only acts on a narrow, deliberate set of events. Everything else is dropped early.
| Gate | Rule |
|---|---|
| Event type | Only issues (action opened), issue_comment (created), and pull_request_review_comment (created). |
| Mention or own thread | The body must contain @smolpaws, or the thread must be one smolpaws itself opened (so it can keep following up on its own issues/PRs). |
| Self-action guard | Events whose sender is smolpaws / smolpaws[bot] are ignored — no replying to itself, no loops. |
| Allowlist | Fail-closed. ALLOWED_ACTORS must be configured; optional ALLOWED_OWNERS, ALLOWED_REPOS, and ALLOWED_INSTALLATIONS narrow it further. The installation allowlist applies only to App webhooks (notifications have no installation id). |
GitHub can redeliver a webhook, and the same mention can surface through both the webhook path and the notification poll. Each candidate gets a stable identity — keyed on the comment id when present, otherwise the issue — and is recorded in the Cloudflare Cache with a 24-hour TTL before it is queued. A second delivery of the same identity is skipped, so a mention runs the agent once, not twice.
// comment-scoped when we have a comment id, else issue-scoped
`${event}:${repo}:comment:${commentId}`
`${event}:${repo}:issue:${issueNumber}`
turnClient, using the GitHub delivery_id as the idempotency key.issues/{number}/comments on the originating issue/PR — the same thread the request came from.GITHUB_WEBHOOK_SECRET; App auth uses GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY to mint short-lived installation tokens.ALLOWED_ACTORS configured, nothing is processed.SMOLPAWS_RUNNER_URL (the agent-server base URL) with SMOLPAWS_RUNNER_TOKEN; the agent runtime and conversation state live behind that boundary, not in the Worker.| Piece | Value |
|---|---|
| Production Worker | https://smolpaws.liberty-labs.org |
| Webhook endpoint | POST /webhooks/github |
| Health | GET /health → ok |
| Notification cron | * * * * * (every minute) |
| Local worker | http://127.0.0.1:8787 |
| Local agent-server | http://127.0.0.1:8788 |
| GitHub | Slack / Discord / WhatsApp | |
|---|---|---|
| Shape | Cloudflare Worker + queue | Long-lived socket bridge inside agent-server |
| Ingestion | Signed webhooks + cron notification poll | Persistent WebSocket / MD connection |
| Thread identity | github-<repo>-<number> from the URL | channel/thread id from the platform event |
| Reply target | Comment on the same issue/PR | Message in the same thread |
Different transport, same contract: receive an event → derive a stable conversation id → dispatch a turn → deliver the result back where it came from. The agent, its state, and its turns all live in the agent-server.