Put an AI Agent Behind a Webhook: Turn GitHub Issues into Pull Requests

TL;DR
Deploy an AI agent behind a GitHub webhook on Railway with OpenCode headless, so a labeled issue becomes a tested pull request. The full build, step by step.
Scheduled agents have a sibling that gets far less attention: event-driven agents. A cron job answers "every day at 07:00", but most of the work an agent should take off your plate is not on a clock. An issue is opened. A bug report lands. A dependency bumps a major version. Each one is an event, and events deserve a webhook, not a schedule.
This guide builds the canonical version end to end: a repo where opening an issue with the agent label triggers a real coding agent that investigates, implements the smallest fix, runs your test suite, and opens a pull request. The receiving end is a small webhook service on Railway, and the worker is OpenCode in headless mode, the same opencode run pattern from our cron automation guide. We run a version of this shape for parts of this site; the mechanics below are the portable core. Seven steps, under an hour, every step ending in something you can run.
Official Sources#
| Resource | Description |
|---|---|
| OpenCode CLI docs | opencode run, headless mode, agent and model flags |
| GitHub webhook docs | Events, payloads, delivery headers, signature validation |
| GitHub webhook best practices | The 10-second response rule and why you need a queue |
| Railway Services | Deploying a service from a GitHub repo or Dockerfile |
| Railway Public Networking | Getting your .railway.app domain and SSL |
| Railway Variables | Secrets and configuration for your service |
| Railway Plans | Plan pricing, included usage, and per-resource rates |
| Railway Free Trial | The one-time $5 trial grant for new accounts |
| Railway Networking Specs and Limits | Edge timeouts, connection and request limits |
Step 1: Install OpenCode and prove headless mode works#
Prerequisites: a GitHub repo you own (a throwaway one is ideal for the first run), a free Railway account (new accounts get a one-time $5 trial grant per Railway's free trial docs, as of 2026-08-13, which covers this build several times over), and an API key for an LLM provider.
Install OpenCode locally with the official one-liner from the OpenCode docs:
curl -fsSL https://opencode.ai/install | bash
Authenticate a provider (opencode auth login), then confirm the single capability the whole pattern depends on - running one task and exiting, no TUI, no interaction:
opencode run --model opencode/deepseek-v4-flash "print the current directory tree, two levels deep"
If that prints a tree and returns cleanly, you have a worker. The DeepSeek V4 Flash 0731 release is the current budget sweet spot for this job at $0.14/$0.28 per million tokens; scheduled and event-driven work is exactly where cheap models earn their keep, with a test gate catching misses. OpenCode reads provider API keys from your environment, which is what lets the same setup work on a server with zero interactive login.
What you have now: a proven headless agent command that will run anywhere.
Step 2: The webhook receiver, in plain Node#
The receiver is deliberately boring: standard library only, two routes, no framework. It does three things: verify the request is really from GitHub, acknowledge it instantly, and hand the payload to the runner in the background.
// server.js
const http = require("node:http");
const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const TRIGGER_LABEL = process.env.TRIGGER_LABEL || "agent";
function signatureMatches(rawBody, header) {
if (!header || !SECRET) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http
.createServer((req, res) => {
if (req.method === "GET" && req.url === "/healthz") {
res.writeHead(200);
return res.end("ok");
}
if (req.method !== "POST" || req.url !== "/webhook") {
res.writeHead(404);
return res.end();
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString();
if (!signatureMatches(raw, req.headers["x-hub-signature-256"])) {
res.writeHead(401);
return res.end("bad signature");
}
const event = JSON.parse(raw);
const isNewAgentIssue =
event.action === "opened" &&
(event.issue?.labels || []).some((l) => l.name === TRIGGER_LABEL);
// GitHub expects a 2xx within 10 seconds. Acknowledge now,
// run the agent in the background.
res.writeHead(202);
res.end("accepted");
if (!isNewAgentIssue) return;
const child = spawn("bash", ["agent.sh"], {
detached: true,
stdio: "ignore",
env: {
...process.env,
REPO: event.repository.full_name,
DEFAULT_BRANCH: event.repository.default_branch,
ISSUE_NUMBER: String(event.issue.number),
ISSUE_TITLE: event.issue.title,
ISSUE_BODY: event.issue.body || "",
},
});
child.unref();
});
})
.listen(process.env.PORT || 3000);
Three details in this file are the security story:
- Signature first. GitHub signs every delivery with your secret as an HMAC-SHA256 in the
X-Hub-Signature-256header. Comparing the digest over the raw body withcrypto.timingSafeEqualis the documented validation pattern, and it stops random internet traffic from spending your tokens. - Never interpolate the payload into a shell string. The title and body cross into the runner through environment variables, so a title full of backticks or
$(rm -rf /)is data, not a command. - The label is the opt-in. Only issues tagged
agenttrigger a run. A typo-filled bug report from a stranger costs you nothing. The issues do not have to be typed, either - the voice memo pipeline turns a 60-second recording into a structured, labeled issue this receiver can pick up.
What you have now: a receiver that can only ever do nothing or spawn a worker. Test it locally with PORT=3000 GITHUB_WEBHOOK_SECRET=test node server.js and a signed curl once Step 3 exists.
Step 3: The agent runner#
One script, six moves: lock against duplicates, fresh clone, one bounded agent run, a test gate, a PR, a comment on the issue. This is the agent-chore.sh pattern from the cron guide adapted for events instead of a schedule.
#!/bin/bash
# agent.sh - runs from the webhook receiver
set -eu
: "${REPO:?}"; : "${ISSUE_NUMBER:?}"; : "${GITHUB_TOKEN:?}"
BRANCH="auto/issue-${ISSUE_NUMBER}"
export GH_TOKEN="$GITHUB_TOKEN"
gh auth setup-git
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# One run per issue: if the branch exists, a previous run is handling it.
if git ls-remote --heads "https://github.com/${REPO}.git" "refs/heads/${BRANCH}" | grep -q .; then
exit 0
fi
git clone --depth=1 "https://github.com/${REPO}.git" "$WORK"
cd "$WORK"
git checkout -b "$BRANCH"
PROMPT="Work on this GitHub issue: https://github.com/${REPO}/issues/${ISSUE_NUMBER}
Title: ${ISSUE_TITLE}
Body:
${ISSUE_BODY}
Implement the smallest complete fix. Run the project's tests before finishing.
Change nothing unrelated to the issue."
timeout 900 opencode run --model "${AGENT_MODEL:-opencode/deepseek-v4-flash}" "$PROMPT"
# Nothing changed? Quiet exit.
git diff --quiet && git diff --cached --quiet && exit 0
# The gate. The agent's opinion of its own work does not count.
if [ -f package.json ]; then npm test; fi
git add -A
git commit -m "fix: resolve issue #${ISSUE_NUMBER}"
git push -u origin "$BRANCH"
PR_URL="$(gh pr create \
--title "Fix #${ISSUE_NUMBER}: ${ISSUE_TITLE}" \
--body "Closes #${ISSUE_NUMBER}" \
--head "$BRANCH" \
--base "${DEFAULT_BRANCH:-main}")"
gh issue comment "$ISSUE_NUMBER" --body "Agent opened ${PR_URL}"
The payload crosses into the runner through environment variables, never as shell input; ISSUE_TITLE and ISSUE_BODY are referenced, not executed. The npm test line is the placeholder for your own gate - pytest, cargo test, npm run verify - whatever your repo treats as green.
Runnable check: run the script once by hand against a dummy issue in a test repo: REPO=you/test ISSUE_NUMBER=1 ISSUE_TITLE="Add a README badge" ISSUE_BODY="..." GITHUB_TOKEN=... ./agent.sh. Watch it clone, run, and push. A runner you have not watched succeed once is not ready to run unattended.
Step 4: The safety rails, before anything is on the internet#
The failure mode of unattended agents is not one bad run, it is bad runs at scale. From production experience, the rails that matter:
- Trigger label, never a catch-all. The
agentlabel is your opt-in per issue. No label, no run. - One run per issue. The
ls-remotecheck makes a second delivery a quiet no-op instead of a conflicting push. - Fresh clone every run. State accumulation is where event-driven agents rot; a clean checkout keeps every run reproducible and every failure explainable.
timeouton the agent. A loop at 2am dies at the bound you set; 900 seconds fits comfortably inside Railway's model for background work.- The gate decides, not the agent. Tests run after the agent finishes and before anything is pushed. If your repo has no tests yet, add a minimal one - a gate that cannot fail is not a gate.
- PRs, never direct pushes. The webhook removes you from the loop; the PR puts you back in at the only point that matters. Keep branch protection on.
- Least-privilege token. A fine-grained personal access token scoped to this one repo with Contents, Issues, and Pull requests at Read and write. If the runner is compromised, the blast radius is one repo and one revocable token.
- Spend caps. The model is cheap and the run is bounded, but events compound; set provider spending alerts before you enable the webhook. The $400 overnight bill post is the canonical failure mode.
What you have now: a runner that is safe to point at real traffic, by construction.
Step 5: Containerize and deploy to Railway#
Three files become a service. The Dockerfile is short because everything is a package: OpenCode installs from npm as opencode-ai, and gh and git come from apt.
FROM node:22-slim
RUN apt-get update && apt-get install -y git gh && rm -rf /var/lib/apt/lists/*
RUN npm install -g opencode-ai
WORKDIR /app
COPY server.js agent.sh ./
RUN chmod +x agent.sh
CMD ["node", "server.js"]
Push server.js, agent.sh, and the Dockerfile to a new GitHub repo. In Railway, create a new project and deploy from that repo - Railway detects the Dockerfile automatically. Then, in the service's Variables tab, add:
| Variable | Value |
|---|---|
GITHUB_WEBHOOK_SECRET | a long random string (you will reuse it in Step 6) |
GITHUB_TOKEN | the fine-grained token from Step 4 |
AGENT_MODEL | optional; defaults to opencode/deepseek-v4-flash |
TRIGGER_LABEL | optional; defaults to agent |
The Variables tab has a RAW editor for pasting a whole .env, and values can be sealed so they are never visible again. The agent inside the container reads the same env vars OpenCode reads locally, so no interactive login is ever needed on the server.
Last, expose it: Settings → Networking → Public Networking → Generate Domain. Railway provisions a *.railway.app domain with automatic SSL, which also satisfies GitHub's requirement to deliver webhooks over HTTPS. Service logs land in the dashboard, and a health check pointed at /healthz gets you uptime monitoring for free.
Runnable check: curl https://<your-service>.up.railway.app/healthz returns ok. Verify the deploy took the Dockerfile path (not the default buildpack) in the deployment logs.
Step 6: Wire GitHub to the endpoint#
In your target repository: Settings → Webhooks → Add webhook. The settings that matter, straight from the GitHub docs:
- Payload URL:
https://<your-service>.up.railway.app/webhook - Content type:
application/json(the JSON payload arrives as the raw request body) - Secret: the same random string from Step 5
- Which events: "Let me select individual events", and subscribe only to
Issues
Save it. GitHub immediately sends a ping event, which is the first end-to-end proof that the endpoint is reachable, signed, and verified - the Recent Deliveries tab shows both sides, and your service logs show the 202.
Runnable check: the delivery shows a 200 or 202 response and your log line, and no run is spawned (a ping has no issue).
Step 7: The first issue that fixes itself#
Open a real issue on the repo - a small, well-scoped task you know how to do, like "the README says port 4000 but the server listens on 3000" - and add the agent label before or right after creating it.
Then watch: the webhook fires, the signature checks, the receiver answers 202 in milliseconds, a clone happens, OpenCode reads the issue and edits code, the test gate runs, a branch lands, and a PR opens titled Fix #N: ... with "Closes #N" in the body. Review it like any other PR - and if you want a second pair of eyes before you even look, the AI reviewer webhook is the matching build: it reviews every PR, including the ones this loop opens. If you would rather run the change than read it, every agent PR can get a live preview environment with its own database and URL. If the diff is wrong, close it, tighten the issue text, and reopen - the loop is built for iteration.
What you have now: a repo where a labeled issue reliably becomes a reviewable pull request. The same receiver and runner generalize to other events with small changes - issue_comment for "do this follow-up", push to main for "post a release summary", or any HTTP client for internal tooling. The shape that matters is constant: verify, acknowledge fast, run one bounded job in a clean checkout, ship the result as a reviewable artifact. The harnesses post says it plainly - the script around the agent is the product. It also pays for itself: on Railway's Hobby plan, which includes $5 of resource usage per month per Railway's plan docs (as of 2026-08-13), a service this small usually stays inside the included usage, and a bounded issue run costs cents in tokens.
FAQ#
How do I stop the agent from acting on every issue?#
Two gates: the TRIGGER_LABEL check means only issues explicitly tagged agent spawn a run, and the signature check means only GitHub deliveries are accepted at all. Everything else gets a 202 and a no-op.
Can it work with a repo that is not Node?#
Yes. The runner is language-agnostic: it clones any repo the token can read, and the npm test line is a placeholder for your repo's own gate (pytest, cargo test, npm run verify). The Dockerfile needs no change.
What happens if an agent run takes longer than Railway allows?#
Railway's edge closes HTTP requests after 5 minutes with no data transferred (up to 15 minutes if data keeps flowing), per Railway's public networking specs as of 2026-08-13, and GitHub terminates webhook deliveries that do not answer within 10 seconds. The build handles both: the receiver answers in milliseconds, and the agent runs as a background process afterward, well inside the 15-minute bound set by timeout 900. If your tasks genuinely need longer, move the runner to a queue-based worker.
Is it safe to let an agent open pull requests automatically?#
Safe enough to let it open PRs, never to merge them. The label is the per-issue opt-in, the token is scoped to one repo, the fresh clone contains the blast radius, and the test gate runs before the branch is pushed. Keep branch protection on; your only job is reviewing.
What does this cost to run?#
On Railway's Hobby plan ($5/month with $5 of included resource usage, per Railway's plan docs as of 2026-08-13), a service this small typically stays inside the included amount. Token costs are cents per issue with a budget model like DeepSeek V4 Flash at $0.14/$0.28 per million tokens. The real cost risk is unbounded loops, which the timeout and spending alerts handle.
Sources#
| Source | URL |
|---|---|
| OpenCode CLI docs | https://opencode.ai/docs/cli/ |
| OpenCode GitHub | https://github.com/anomalyco/opencode |
| GitHub webhook events and payloads | https://docs.github.com/en/webhooks/webhook-events-and-payloads |
| GitHub webhook best practices | https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks |
| Validating webhook deliveries | https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries |
| GitHub REST: create a pull request | https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request |
| Railway Services | https://docs.railway.com/reference/services |
| Railway Public Networking | https://docs.railway.com/reference/public-networking |
| Railway Variables | https://docs.railway.com/develop/variables |
| Railway Plans | https://docs.railway.com/reference/pricing/plans |
| Railway Free Trial | https://docs.railway.com/reference/pricing/free-trial |
| Railway Networking Specs and Limits | https://docs.railway.com/networking/public-networking/specs-and-limits |
Some links to tools above are referral links - see our affiliate disclosure.
Last updated: August 13, 2026
Continue Reading#
- Put an AI Agent on a Cron Job - the scheduled sibling of this pattern: the same runner, a clock instead of a webhook
- Put an AI Reviewer on Every Pull Request - the review-side sibling: comments on every PR this build opens, never writes code
- OpenCode Developer Guide 2026 - the full tour of the CLI doing the work in this build
- DeepSeek V4 Flash 0731 in OpenCode - the budget model that makes event-driven agents cheap enough to ignore
- Long-Running Agents Need Harnesses - why the script around the agent matters more than the agent
- The $400 Overnight Bill - agent FinOps, learned the hard way
- Ship a Remote MCP Server: Give Your Coding Agent Cloud Tools in an Afternoon - 'Ship a Remote MCP Server: Give Your Coding Agent Cloud Tools in an Af
- Automate Video Editing with the Descript API - an async job pipeline where every step accepts the webhook trigger from this build
- Turn a Voice Memo into a GitHub Issue - the input side: spoken bug reports become labeled issues this receiver answers with PRs
- Put Your Coding Agent in Discord - the chat sibling: the same headless run exposed as a slash command instead of a webhook
- Run Your Coding Agent as an API - the same worker kept warm behind an authenticated HTTP API instead of being spawned per event
- OpenCode - the headless agent behind this build, pricing and model support on one page
- Best AI Agent Frameworks (2026) - other ways to put an agent behind a trigger
Get the next deep dive like this in your inbox
One email a week on railway and the rest of the AI dev stack. Free.
Read next on AI coding tools
Put an AI Agent on a Cron Job: Automating Dev Chores with OpenCode
Put an AI agent on a cron job with OpenCode: a runner script, a schedule, first chores and guardrails, so recurring dev work arrives as PRs to review.
11 min readOpenCode Developer Guide: The Open Source AI Coding Agent with 160K Stars
OpenCode developer guide - install the open source, model-agnostic AI coding agent, pick models across 75+ providers and compare it with Claude Code.
11 min readDeepSeek V4 Flash 0731: The Official Release, Benchmarks, and How to Run It in OpenCode
DeepSeek V4 Flash 0731 is the official V4 Flash API release, re-post-trained to beat V4-Pro-Preview on agent benchmarks. How to run it in OpenCode today.
7 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.








