π€ AI-powered code modification runner using OpenHands SDK
Agent-Runner is a GitHub Actions-based automation tool that:
- Clones a forked repository
- Runs an AI agent (powered by OpenHands SDK) to make code changes
- Commits and pushes the changes
- Creates a Pull Request to the upstream repository
- Notifies your backend via webhook when complete
βββββββββββββββ 1. Submit Job ββββββββββββββββββββ
β β ββββββββββββββββββββΆ β β
β Your App β β Backend Service β
β β ββββββββββββββββββββ β (agent_runner) β
βββββββββββββββ 5. Webhook ββββββββββββββββββββ
Callback β
β 2. Fork Repo
β 3. Trigger Workflow
βΌ
ββββββββββββββββββββ
β GitHub Actions β
β (run.yml) β
β β
β β’ Clone fork β
β β’ Run AI Agent β
β β’ Commit/Push β
β β’ Create PR β
β β’ Send Callback β
ββββββββββββββββββββ
Add these secrets to your Agent-Runner repository:
| Secret | Description |
|---|---|
BOT_TOKEN |
GitHub PAT with repo scope |
LLM_API_KEY |
API key for your LLM provider |
LLM_MODEL |
(Optional) Model name, defaults to anthropic/claude-sonnet-4-5-20250929 |
WEBHOOK_SECRET |
Secret for signing/validating webhook callbacks (recommended; required unless ALLOW_INSECURE_WEBHOOKS=1) |
The primary way to use Agent-Runner is through GitHub's workflow_dispatch API:
curl -X POST \
-H "Authorization: Bearer <BOT_TOKEN>" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/<your-org>/Agent-Runner/actions/workflows/run.yml/dispatches \
-d '{
"ref": "main",
"inputs": {
"fork_repo": "bot/repo",
"upstream_repo": "owner/repo",
"prompt": "Fix the typo in README.md",
"job_id": "job-123",
"callback_url": "https://your-backend.com/webhook"
}
}'If you prefer an HTTP API interface:
# Install with server extras
pip install -e '.[server]'
# Set environment variables
export BOT_TOKEN="ghp_xxx"
export RUNNER_REPO="your-org/Agent-Runner"
export BOT_USERNAME="your-bot-username"
export WEBHOOK_SECRET="your-secret-key"
# Run server
uvicorn server.app:app --host 0.0.0.0 --port 8000curl -X POST http://localhost:8000/api/jobs \
-H "Content-Type: application/json" \
-d '{
"upstream_repo": "owner/repo",
"prompt": "Fix the typo in README.md",
"callback_url": "https://your-app.com/webhook/agent-runner"
}'Response:
{
"job_id": "job-abc123def456",
"status": "triggered",
"upstream_repo": "owner/repo",
"fork_repo": "your-bot/repo",
"branch": "bot/job-abc123def456"
}| Input | Description | Required |
|---|---|---|
fork_repo |
Fork repository path, e.g. bot/repo |
β |
upstream_repo |
Upstream repository path, e.g. owner/repo |
β |
prompt |
Instructions for the AI agent | β |
job_id |
Unique identifier for tracking | β |
callback_url |
URL to POST results when complete | β |
When the workflow completes, it sends a POST request to your callback_url:
{
"job_id": "job-abc123def456",
"status": "completed",
"pr_url": "https://github.com/owner/repo/pull/123",
"upstream_repo": "owner/repo",
"fork_repo": "bot/repo",
"branch": "bot/job-abc123def456"
}pr_url may be null if no PR was created (e.g., no changes).
{
"job_id": "job-abc123def456",
"status": "failed",
"error": "Workflow failed. Check GitHub Actions logs for details.",
"upstream_repo": "owner/repo",
"fork_repo": "bot/repo"
}If WEBHOOK_SECRET is configured, the callback includes an X-Signature-256 header (custom header used by Agent-Runner callbacks):
X-Signature-256: sha256=<HMAC-SHA256 of payload>
Verify in your backend:
import hmac
import hashlib
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)import asyncio
from agent_runner import AgentRunner
async def main():
# Initialize the runner
runner = AgentRunner(
bot_token="ghp_xxxxxxxxxxxx", # GitHub PAT with repo scope
runner_repo="your-org/Agent-Runner", # This repository
bot_username="your-bot-username", # GitHub bot account username
webhook_secret="your-secret-key", # Required to verify callback signatures (recommended)
)
# Submit a job
job = await runner.submit_job(
upstream_repo="vercel/next.js",
prompt="Fix the typo in README.md where 'teh' should be 'the'",
callback_url="https://your-backend.com/webhook/agent-runner",
)
print(f"Job ID: {job.job_id}")
print(f"Status: {job.status.value}")
print(f"Fork: {job.fork_repo}")
print(f"Branch: {job.branch}")
asyncio.run(main())import os
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
from agent_runner import AgentRunner, JobStatus
app = FastAPI()
# Initialize runner (do this once at startup)
runner = AgentRunner(
bot_token=os.environ["BOT_TOKEN"],
runner_repo=os.environ["RUNNER_REPO"],
bot_username=os.environ["BOT_USERNAME"],
webhook_secret=os.environ.get("WEBHOOK_SECRET"),
allow_insecure_webhooks=os.environ.get("ALLOW_INSECURE_WEBHOOKS") == "1",
)
@app.on_event("shutdown")
async def shutdown():
await runner.close()
class SubmitRequest(BaseModel):
upstream_repo: str
prompt: str
callback_url: str | None = None
@app.post("/api/jobs")
async def submit_job(request: SubmitRequest):
"""Submit a new agent runner job."""
try:
job = await runner.submit_job(
upstream_repo=request.upstream_repo,
prompt=request.prompt,
callback_url=request.callback_url,
)
return job.to_dict()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
"""Get job status by ID."""
job = runner.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job.to_dict()
@app.post("/webhook/agent-runner")
async def handle_callback(request: Request):
"""Handle workflow completion callback."""
# Verify signature
signature = request.headers.get("X-Signature-256", "")
body = await request.body()
if not runner.verify_webhook_signature(body, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
data = await request.json()
job = runner.update_job_from_callback(
job_id=data["job_id"],
status=data["status"],
pr_url=data.get("pr_url"),
error=data.get("error"),
)
# Do something with the completed job
if job and job.status == JobStatus.COMPLETED:
print(f"π PR created: {job.pr_url}")
return {"status": "ok"}import os
from flask import Flask, request, jsonify
import asyncio
from agent_runner import AgentRunner
app = Flask(__name__)
runner = AgentRunner(
bot_token=os.environ["BOT_TOKEN"],
runner_repo=os.environ["RUNNER_REPO"],
bot_username=os.environ["BOT_USERNAME"],
webhook_secret=os.environ.get("WEBHOOK_SECRET"),
allow_insecure_webhooks=os.environ.get("ALLOW_INSECURE_WEBHOOKS") == "1",
)
@app.route("/api/jobs", methods=["POST"])
def submit_job():
data = request.json
# Run async code in sync context
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
job = loop.run_until_complete(
runner.submit_job(
upstream_repo=data["upstream_repo"],
prompt=data["prompt"],
callback_url=data.get("callback_url"),
)
)
return jsonify(job.to_dict()), 201
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
loop.close()
@app.route("/webhook/agent-runner", methods=["POST"])
def handle_callback():
signature = request.headers.get("X-Signature-256", "")
body = request.get_data()
if not runner.verify_webhook_signature(body, signature):
return jsonify({"error": "Invalid signature"}), 401
data = request.get_json() or {}
job = runner.update_job_from_callback(
job_id=data["job_id"],
status=data["status"],
pr_url=data.get("pr_url"),
error=data.get("error"),
)
return jsonify({"status": "ok"})runner = AgentRunner(
bot_token="ghp_xxx",
runner_repo="your-org/Agent-Runner",
bot_username="your-bot",
# Advanced options
webhook_secret="your-hmac-secret", # For callback signature verification
allow_insecure_webhooks=False, # Set True only for local dev without webhook signatures
fork_timeout=180, # Max seconds to wait for fork (default: 120)
fork_poll_interval=3, # Seconds between fork status checks (default: 5)
)from agent_runner import JobStatus
job = runner.get_job("job-abc123")
if job.status == JobStatus.PENDING:
print("Job is waiting to start")
elif job.status == JobStatus.FORKING:
print("Creating fork...")
elif job.status == JobStatus.TRIGGERED:
print("Workflow triggered, waiting for completion")
elif job.status == JobStatus.COMPLETED:
print(f"Done! PR: {job.pr_url}")
elif job.status == JobStatus.FAILED:
print(f"Failed: {job.error}")Submit a new agent runner job.
Request Body:
{
"upstream_repo": "owner/repo",
"prompt": "Your instructions here",
"callback_url": "https://your-webhook.com/callback"
}Response (201):
{
"job_id": "job-xxx",
"status": "triggered",
...
}Get job status.
Response (200):
{
"job_id": "job-xxx",
"status": "completed",
"pr_url": "https://github.com/...",
...
}Internal endpoint for workflow callbacks.
You can also trigger the workflow directly without the backend:
curl -X POST \
-H "Authorization: Bearer <BOT_TOKEN>" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/<your-org>/Agent-Runner/actions/workflows/run.yml/dispatches \
-d '{
"ref": "main",
"inputs": {
"fork_repo": "bot/repo",
"upstream_repo": "owner/repo",
"prompt": "Fix the typo",
"job_id": "job-123",
"callback_url": "https://your-backend.com/webhook"
}
}'export LLM_API_KEY="your-anthropic-or-openai-key"
export LLM_MODEL="anthropic/claude-sonnet-4-5-20250929"Sign up at OpenHands Cloud for verified models.
export LLM_API_KEY="your-openhands-api-key"
export LLM_MODEL="openhands/claude-sonnet-4-5-20250929"Agent-Runner/
βββ .github/
β βββ workflows/
β βββ run.yml # GitHub Actions workflow (thin, calls Python CLI)
βββ src/
β βββ agent_runner/
β βββ __init__.py # Package exports
β βββ cli.py # CLI entry point (submit, run, pr, callback)
β βββ core.py # Core AgentRunner service
β βββ models.py # Data models (Job, JobStatus)
β βββ callback.py # Webhook callback handling
β βββ github/
β βββ client.py # GitHub API client
β βββ repo.py # Repository operations (fork, sync)
β βββ pr.py # Pull request operations
β βββ workflow.py # Workflow dispatch
βββ scripts/
β βββ sync_fork.py # Sync fork with upstream
β βββ commit_push.py # Commit and push changes
βββ server/
β βββ app.py # Optional FastAPI HTTP server
βββ pyproject.toml # Python package configuration
βββ requirements.txt # Core dependencies
βββ LICENSE
βββ README.md
- π Never commit API keys - use GitHub Secrets
- π Use minimal PAT permissions (only
reposcope needed) - π Configure
WEBHOOK_SECRETfor callback signature verification - π Validate and sanitize prompts before passing to the agent
- π Consider rate limiting on your backend
If fork creation times out, increase fork_timeout in AgentRunner config:
runner = AgentRunner(
...,
fork_timeout=180, # 3 minutes
)- Check that
BOT_TOKENhasreposcope - Verify the workflow file exists at
.github/workflows/run.yml - Check GitHub Actions is enabled for the repository
Check the GitHub Actions logs for detailed error messages. Common issues:
- Invalid
LLM_API_KEY - Rate limiting from LLM provider
- Repository too large for agent to process