← Home

SmolPaws GitHub Entry Point live

How the GitHub ingress receives webhooks and notifications, maps each issue/PR to one durable conversation, and replies in-thread.
Source: apps/github/  ·  Conversation id: agentServerClient.ts  ·  Turn client: turnClient.ts

Overview

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:

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
    

The key idea: one thread, one conversation

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 threadConversation id
owner/repo issue #42github-owner-repo-42
owner/repo PR #118github-owner-repo-118

What triggers a run

The Worker only acts on a narrow, deliberate set of events. Everything else is dropped early.

GateRule
Event typeOnly issues (action opened), issue_comment (created), and pull_request_review_comment (created).
Mention or own threadThe 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 guardEvents whose sender is smolpaws / smolpaws[bot] are ignored — no replying to itself, no loops.
AllowlistFail-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).

Deduplication

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

From queue to reply

  1. The consumer resolves a GitHub token — an installation token for App webhooks, or the user token for notification-sourced events.
  2. It builds the conversation id and submits a turn to the agent-server via the shared turnClient, using the GitHub delivery_id as the idempotency key.
  3. It waits for the turn result and any outbound messages the agent produced.
  4. If there is a reply to post (and the outbound-message check says it should), it posts a comment back to issues/{number}/comments on the originating issue/PR — the same thread the request came from.

Security boundaries

Where it runs

PieceValue
Production Workerhttps://smolpaws.liberty-labs.org
Webhook endpointPOST /webhooks/github
HealthGET /healthok
Notification cron* * * * * (every minute)
Local workerhttp://127.0.0.1:8787
Local agent-serverhttp://127.0.0.1:8788

How it differs from the socket bridges

GitHubSlack / Discord / WhatsApp
ShapeCloudflare Worker + queueLong-lived socket bridge inside agent-server
IngestionSigned webhooks + cron notification pollPersistent WebSocket / MD connection
Thread identitygithub-<repo>-<number> from the URLchannel/thread id from the platform event
Reply targetComment on the same issue/PRMessage 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.

← Home