Gateway
The gateway is a builtin plugin that exposes an HTTP webhook receiver on a separate port. External services (GitHub, Gitea, GitLab, AWS Batch, etc.) can send webhook events that trigger TF Code agent runs. The agent works in your local repository and uses its tools (bash, gh CLI, curl, etc.) to interact with the source system.
Architecture
External Service → POST webhook → Gateway (port 8644)
→ HMAC validation
→ IP allowlist check
→ Event type + payload filter
→ Prompt template rendering
→ Session creation (in-process)
→ Agent runs autonomously
→ Agent updates ticket via its own tools
The gateway is intentionally dumb — it translates external events into agent prompts. The agent is smart — it has tools and figures out how to interact with whatever system the event came from.
Enable
Add to your tfcode.json:
{
"builtin": {
"gateway": { "enabled": true }
},
"gateway": {
"port": 8644,
"secret": "your-global-hmac-secret",
"routes": {}
}
}
Start the TF Code server from the directory containing your cloned repo:
cd /home/user/projects/my-repo
tfcode serve
The gateway starts automatically when the gateway builtin plugin is enabled and a gateway config section exists.
Health Check
curl http://localhost:8644/health
# {"status":"ok","platform":"webhook"}
Configuration
Gateway-level settings
| Field | Default | Description |
|---|---|---|
port | 8644 | Webhook server port |
host | 0.0.0.0 | Bind address |
secret | — | Global HMAC secret (fallback for routes without their own) |
allowed_ips | — | Global IP/CIDR allowlist |
trusted_proxy | — | Reverse proxy IP for X-Forwarded-For trust |
rate_limit | 30 | Requests per minute per route |
max_body_bytes | 1048576 | Max body size (1MB default) |
routes | — | Route definitions (see below) |
Route settings
| Field | Required | Description |
|---|---|---|
secret | Yes | HMAC secret (falls back to gateway.secret). Use "INSECURE_NO_AUTH" for testing (loopback only) |
events | No | Event types to accept (e.g. ["issues"]). If empty, all events accepted |
filter | No | Payload field filters (AND logic). Dot-notation keys: {"action": "labeled", "label.name": "tfcode_work"} |
prompt | No | Template with {dot.notation} payload access. {__raw__} dumps full payload. If omitted, full payload is used |
agent | No | TF Code agent to use (default: "build") |
log | No | Skip agent — just log the event (testing mode) |
allowed_ips | No | Per-route IP/CIDR allowlist (overrides global) |
directory | No | Override working directory for agent session |
sync | No | Synchronous mode — webhook blocks until agent finishes, returns response in HTTP body |
Prompt templates
Templates use dot-notation to access nested fields in the webhook payload:
{pull_request.title}resolves topayload["pull_request"]["title"]{repository.full_name}resolves topayload["repository"]["full_name"]{__raw__}dumps the entire payload as indented JSON (truncated at 4000 characters)- Missing keys are left as the literal
{key}string (no error) - Nested dicts and lists are JSON-serialized (truncated at 2000 characters)
If no prompt template is configured, the entire payload is used as the prompt.
Security
HMAC Signature Validation
The gateway validates signatures using the appropriate method for each source:
- GitHub:
X-Hub-Signature-256header (HMAC-SHA256 hex digest prefixed withsha256=) - Gitea:
X-Gitea-Signatureheader (HMAC-SHA256 hex digest) - GitLab:
X-Gitlab-Tokenheader (plain secret string match) - Generic:
X-Webhook-Signatureheader (raw HMAC-SHA256 hex digest)
Every route must have a secret — either set directly or inherited from the global gateway.secret.
IP Allowlist
Restrict which IPs can send webhooks:
{
"gateway": {
"allowed_ips": ["192.0.2.0/24", "203.0.113.0/24"]
}
}
Per-route override:
{
"gateway": {
"routes": {
"internal-only": {
"secret": "my-secret",
"allowed_ips": ["10.0.0.0/8"]
}
}
}
}
GitHub publishes webhook IP ranges at https://api.github.com/meta (field: hooks).
INSECURE_NO_AUTH
Setting a route's secret to "INSECURE_NO_AUTH" skips HMAC validation. This is only allowed when the gateway is bound to a loopback address (127.0.0.1, localhost, ::1). The gateway refuses to start if INSECURE_NO_AUTH is combined with a non-loopback bind.
Rate Limiting
Each route is rate-limited to 30 requests per minute by default. Configure globally:
{
"gateway": {
"rate_limit": 60
}
}
Idempotency
Delivery IDs (from X-GitHub-Delivery, X-Request-ID, or a generated UUID) are cached for 1 hour. Duplicate deliveries return 200 {status: "duplicate"} and do not trigger the agent.
TLS / HTTPS
The gateway serves plain HTTP. Use a reverse proxy (nginx, caddy) for TLS termination. Set trusted_proxy to the proxy's IP so the gateway trusts X-Forwarded-For:
{
"gateway": {
"trusted_proxy": "127.0.0.1"
}
}
CLI Commands
Create a subscription
tfcode gateway subscribe github-issues \
--events "issues" \
--filter "action=labeled,label.name=tfcode_work" \
--prompt "Issue #{issue.number}: {issue.title}\n{issue.body}\n\nWork in the current repo and post a comment when done." \
--secret "my-hmac-secret"
List subscriptions
tfcode gateway list
Remove a subscription
tfcode gateway remove github-issues
Test a subscription
tfcode gateway test github-issues --payload '{"issue":{"number":42,"title":"Test"}}'
Dynamic subscriptions are stored in .tfcode/gateway/subscriptions.json and hot-reloaded on each request. Static routes from tfcode.json take precedence over dynamic ones with the same name.
Examples
GitHub Issue Label Trigger
Trigger an agent run when an issue is labeled tfcode_work:
{
"builtin": { "gateway": { "enabled": true } },
"gateway": {
"port": 8644,
"allowed_ips": ["192.0.2.0/24"],
"routes": {
"tfcode-work": {
"events": ["issues"],
"secret": "github-webhook-secret",
"filter": { "action": "labeled", "label.name": "tfcode_work" },
"prompt": "You received a webhook from GitHub.\n\nEvent: issues (action: {action})\nRepository: {repository.full_name}\nIssue #{issue.number}: {issue.title}\nURL: {issue.html_url}\nTriggered by label: {label.name}\n\nIssue description:\n{issue.body}\n\nInstructions:\n- Work in the current repository to resolve this issue\n- When done, post a comment on the issue summarizing your changes\n- Remove the 'tfcode_work' label and add 'tfcode_done'\n- Use gh CLI to interact with GitHub\n\nFull payload:\n{__raw__}"
}
}
}
}
- Create a webhook in GitHub: Repository Settings → Webhooks → Add webhook
- Set Payload URL to
http://your-server:8644/webhooks/tfcode-work - Set Content type to
application/json - Set Secret to match your route config
- Select "Let me select individual events" → check "Issues"
- Open an issue and add the
tfcode_worklabel
The agent will work in the cloned repo, fix the issue, run tests, and post a comment on the GitHub issue using gh issue comment.
Gitea Merge Request Review
{
"gateway": {
"routes": {
"gitea-mr": {
"events": ["merge_request"],
"secret": "gitea-webhook-secret",
"filter": { "action": "opened" },
"prompt": "Review this merge request:\nProject: {project.path_with_namespace}\nMR !{object_attributes.iid}: {object_attributes.title}\nURL: {object_attributes.url}\n\nUse the Gitea API (token in $GITEA_TOKEN) to post review comments."
}
}
}
}
AWS Batch Synchronous Job
For AWS Batch jobs that need the agent's response in the HTTP response:
{
"gateway": {
"routes": {
"batch-job": {
"secret": "batch-webhook-secret",
"sync": true,
"prompt": "Process this job: {__raw__}"
}
}
}
}
The webhook will block until the agent finishes and return the response:
{
"status": "completed",
"route": "batch-job",
"session": "sess_abc123",
"response": "Job completed successfully..."
}
Direct Delivery (No Agent)
Skip the agent entirely and just log the event for testing:
{
"gateway": {
"routes": {
"test-route": {
"secret": "INSECURE_NO_AUTH",
"log": true,
"prompt": "Test event: {action} on {repository.full_name}"
}
}
}
}
Response Codes
| Status | Meaning |
|---|---|
200 {status: "accepted"} | Webhook accepted, agent dispatched asynchronously |
200 {status: "completed"} | Sync mode — agent finished, response in body |
200 {status: "logged"} | Log mode — event logged, agent not invoked |
200 {status: "duplicate"} | Duplicate delivery ID within idempotency TTL |
200 {status: "filtered"} | Event type or payload filter didn't match |
401 | HMAC signature invalid or missing |
403 | Source IP not in allowlist |
404 | Unknown route name |
413 | Body exceeded max_body_bytes |
429 | Rate limit exceeded |
500 | Server misconfigured (no secret) or session creation failed |
502 | Sync mode — agent failed |
Session Management
The gateway maps webhook sources to TF Code sessions using a composite key:
source:repository:event_type:entity_id
For example: github:owner/repo:issues:42
Multiple webhooks for the same issue/PR reuse the same session, so the agent maintains conversation context across multiple events. New issues/PRs create new sessions.
Sessions created by the gateway deny interactive permissions (question, plan_enter, plan_exit) since there's no human to answer prompts.