Skip to main content

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

FieldDefaultDescription
port8644Webhook server port
host0.0.0.0Bind address
secretGlobal HMAC secret (fallback for routes without their own)
allowed_ipsGlobal IP/CIDR allowlist
trusted_proxyReverse proxy IP for X-Forwarded-For trust
rate_limit30Requests per minute per route
max_body_bytes1048576Max body size (1MB default)
routesRoute definitions (see below)

Route settings

FieldRequiredDescription
secretYesHMAC secret (falls back to gateway.secret). Use "INSECURE_NO_AUTH" for testing (loopback only)
eventsNoEvent types to accept (e.g. ["issues"]). If empty, all events accepted
filterNoPayload field filters (AND logic). Dot-notation keys: {"action": "labeled", "label.name": "tfcode_work"}
promptNoTemplate with {dot.notation} payload access. {__raw__} dumps full payload. If omitted, full payload is used
agentNoTF Code agent to use (default: "build")
logNoSkip agent — just log the event (testing mode)
allowed_ipsNoPer-route IP/CIDR allowlist (overrides global)
directoryNoOverride working directory for agent session
syncNoSynchronous 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 to payload["pull_request"]["title"]
  • {repository.full_name} resolves to payload["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-256 header (HMAC-SHA256 hex digest prefixed with sha256=)
  • Gitea: X-Gitea-Signature header (HMAC-SHA256 hex digest)
  • GitLab: X-Gitlab-Token header (plain secret string match)
  • Generic: X-Webhook-Signature header (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__}"
}
}
}
}
  1. Create a webhook in GitHub: Repository Settings → Webhooks → Add webhook
  2. Set Payload URL to http://your-server:8644/webhooks/tfcode-work
  3. Set Content type to application/json
  4. Set Secret to match your route config
  5. Select "Let me select individual events" → check "Issues"
  6. Open an issue and add the tfcode_work label

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

StatusMeaning
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
401HMAC signature invalid or missing
403Source IP not in allowlist
404Unknown route name
413Body exceeded max_body_bytes
429Rate limit exceeded
500Server misconfigured (no secret) or session creation failed
502Sync 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.