Building a Custom TameFlare Connector in Go
TameFlare ships with 7 built-in connectors, but your agents probably call APIs we haven't covered yet. This guide walks through building a custom connector from scratch — domain matching, request parsing, credential injection, and registration.
Why build a custom connector?
TameFlare ships with 7 built-in connectors: GitHub, OpenAI, Anthropic, Stripe, Slack, generic HTTP, and webhook. These cover the most common APIs that AI agents call.
But your agents probably call APIs we haven't covered yet — Jira, Linear, Notion, AWS, Datadog, PagerDuty, or your own internal services. Without a connector, these requests still pass through the proxy, but they're handled by the generic HTTP connector, which can only match by domain and HTTP method. You lose the ability to parse requests into structured action types like jira.issue.create or notion.page.update.
A custom connector gives you:
jira.issue.create instead of POST api.atlassian.comThe Connector interface
Every connector implements a Go interface defined in internal/connectors/interface.go:
type Connector interface {
// Type returns the connector identifier (e.g. "jira", "notion")
Type() string
// DisplayName returns a human-readable name
DisplayName() string
// MatchesDomain returns true if this connector handles the given domain
MatchesDomain(domain string) bool
// ParseRequest converts an HTTP request into a structured action
ParseRequest(req *http.Request) (*ParsedAction, error)
// InjectCredentials adds authentication to the outbound request
InjectCredentials(req *http.Request) error
// HealthCheck verifies connectivity to the upstream service
HealthCheck(ctx context.Context) error
}
And the structured action it produces:
type ParsedAction struct {
Name string `json:"name"` // "Create issue"
ActionType string `json:"action_type"` // "jira.issue.create"
Method string `json:"method"` // "POST"
URL string `json:"url"` // target URL
Parameters map[string]any `json:"parameters"` // parsed fields
RiskLevel string `json:"risk_level"` // low | medium | high
}
Step 1: Create the connector package
Create a new directory under internal/connectors/:
mkdir apps/gateway-v2/internal/connectors/jira
touch apps/gateway-v2/internal/connectors/jira/connector.go
Step 2: Implement the struct and constructor
package jira
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/tameflare/tameflare/apps/gateway-v2/internal/connectors"
)
var jiraDomains = []string{
"api.atlassian.com",
}
type JiraConnector struct {
id string
displayName string
token string
}
func New(id, displayName, token string) *JiraConnector {
return &JiraConnector{
id: id,
displayName: displayName,
token: token,
}
}
func (c *JiraConnector) Type() string { return "jira" }
func (c *JiraConnector) DisplayName() string { return c.displayName }
Step 3: Domain matching
The MatchesDomain method tells the registry which HTTP requests belong to this connector:
func (c *JiraConnector) MatchesDomain(domain string) bool {
domain = strings.ToLower(domain)
for _, d := range jiraDomains {
if domain == d {
return true
}
}
// Match Jira Cloud instances: *.atlassian.net
return strings.HasSuffix(domain, ".atlassian.net")
}
func (c *JiraConnector) Domains() []string {
return jiraDomains
}
Step 4: Parse requests into structured actions
This is where the real value is. The ParseRequest method inspects the HTTP method and URL path to determine what action the agent is performing:
func (c *JiraConnector) ParseRequest(req *http.Request) (*connectors.ParsedAction, error) {
path := req.URL.Path
method := req.Method
// POST /rest/api/3/issue — create issue
if method == "POST" && strings.HasSuffix(path, "/rest/api/3/issue") {
return &connectors.ParsedAction{
Name: "Create issue",
ActionType: "jira.issue.create",
Method: method,
URL: req.URL.String(),
Parameters: map[string]any{},
RiskLevel: "low",
}, nil
}
// DELETE /rest/api/3/issue/{issueIdOrKey} — delete issue
if method == "DELETE" && strings.Contains(path, "/rest/api/3/issue/") {
parts := strings.Split(path, "/")
issueKey := parts[len(parts)-1]
return &connectors.ParsedAction{
Name: "Delete issue",
ActionType: "jira.issue.delete",
Method: method,
URL: req.URL.String(),
Parameters: map[string]any{"issue_key": issueKey},
RiskLevel: "high",
}, nil
}
// PUT /rest/api/3/issue/{issueIdOrKey} — update issue
if method == "PUT" && strings.Contains(path, "/rest/api/3/issue/") {
parts := strings.Split(path, "/")
issueKey := parts[len(parts)-1]
return &connectors.ParsedAction{
Name: "Update issue",
ActionType: "jira.issue.update",
Method: method,
URL: req.URL.String(),
Parameters: map[string]any{"issue_key": issueKey},
RiskLevel: "medium",
}, nil
}
// Fallback for unrecognized Jira API calls
return &connectors.ParsedAction{
Name: fmt.Sprintf("Jira API: %s %s", method, path),
ActionType: "jira.unknown",
Method: method,
URL: req.URL.String(),
Parameters: map[string]any{"path": path},
RiskLevel: "medium",
}, nil
}
Tips for writing good parsers:
RiskLevel based on the operation: reads are low, writes are medium, deletes are highservice.resource.action (e.g., jira.issue.create)Step 5: Credential injection
The proxy calls InjectCredentials before forwarding the request upstream. This is where you add the authentication header:
func (c *JiraConnector) InjectCredentials(req *http.Request) error {
if c.token == "" {
return fmt.Errorf("no Jira API token configured")
}
// Remove any existing auth from the agent
req.Header.Del("Authorization")
// Jira Cloud uses Bearer token auth
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
return nil
}
func (c *JiraConnector) SetToken(token string) {
c.token = token
}
The agent process never has the real API token — the proxy injects it only into requests that pass policy evaluation.
Step 6: Health check
The health check verifies that the upstream service is reachable and the credentials are valid:
func (c *JiraConnector) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET",
"https://api.atlassian.com/me", nil)
if err != nil {
return err
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("Jira API unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
return fmt.Errorf("Jira token is invalid or expired")
}
if resp.StatusCode != 200 {
return fmt.Errorf("Jira API returned %d", resp.StatusCode)
}
return nil
}
Step 7: Register the connector
In cmd/gateway/main.go, import your package and register the connector with the registry:
import (
jiraconn "github.com/tameflare/tameflare/apps/gateway-v2/internal/connectors/jira"
)
// In the startup function:
jira := jiraconn.New("jira-cloud", "Jira Cloud", vaultToken)
registry.Register("jira-cloud", jira)
for _, domain := range jira.Domains() {
registry.AddDomainMapping(domain, "jira-cloud")
}
The registry uses domain matching to route requests. When the proxy sees a request to api.atlassian.com, it looks up the domain in the registry, finds your Jira connector, and calls ParseRequest to get the structured action.
Step 8: Add credentials to the vault
Store the API token in the TameFlare credential vault via the CLI:
npx tf connector add --type jira --name "Jira Cloud" --domain api.atlassian.com --credential "your-jira-api-token"
The vault encrypts credentials with AES-256-GCM. The token is only decrypted at request time, injected into the outbound request, and never exposed to the agent process.
Step 9: Set permissions
Configure which gateways can use the new connector:
# Allow the "devops" gateway to use Jira (read + write)
npx tf permissions set --gateway devops --connector jira --permission allow_all
# Allow the "support" gateway to read only
npx tf permissions set --gateway support --connector jira --permission read_only
Writing policies for custom actions
Once your connector is registered, you can write policies that target the structured action types:
jira.issue.delete → denyjira.issue.update + rule parameters.bulk equals true → require_approvaljira.issue.get → allowPolicies are configured in the TameFlare dashboard using the Policy Builder — no code required.
The complete file
Here's the full connector in one block for easy copy-paste:
package jira
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/tameflare/tameflare/apps/gateway-v2/internal/connectors"
)
var jiraDomains = []string{"api.atlassian.com"}
type JiraConnector struct {
id, displayName, token string
}
func New(id, displayName, token string) *JiraConnector {
return &JiraConnector{id: id, displayName: displayName, token: token}
}
func (c *JiraConnector) Type() string { return "jira" }
func (c *JiraConnector) DisplayName() string { return c.displayName }
func (c *JiraConnector) MatchesDomain(domain string) bool {
domain = strings.ToLower(domain)
for _, d := range jiraDomains {
if domain == d { return true }
}
return strings.HasSuffix(domain, ".atlassian.net")
}
func (c *JiraConnector) Domains() []string { return jiraDomains }
func (c *JiraConnector) ParseRequest(req *http.Request) (*connectors.ParsedAction, error) {
path, method := req.URL.Path, req.Method
if method == "POST" && strings.HasSuffix(path, "/rest/api/3/issue") {
return &connectors.ParsedAction{Name: "Create issue", ActionType: "jira.issue.create", Method: method, URL: req.URL.String(), RiskLevel: "low"}, nil
}
if method == "DELETE" && strings.Contains(path, "/rest/api/3/issue/") {
parts := strings.Split(path, "/")
return &connectors.ParsedAction{Name: "Delete issue", ActionType: "jira.issue.delete", Method: method, URL: req.URL.String(), Parameters: map[string]any{"issue_key": parts[len(parts)-1]}, RiskLevel: "high"}, nil
}
if method == "PUT" && strings.Contains(path, "/rest/api/3/issue/") {
parts := strings.Split(path, "/")
return &connectors.ParsedAction{Name: "Update issue", ActionType: "jira.issue.update", Method: method, URL: req.URL.String(), Parameters: map[string]any{"issue_key": parts[len(parts)-1]}, RiskLevel: "medium"}, nil
}
return &connectors.ParsedAction{Name: fmt.Sprintf("Jira API: %s %s", method, path), ActionType: "jira.unknown", Method: method, URL: req.URL.String(), Parameters: map[string]any{"path": path}, RiskLevel: "medium"}, nil
}
func (c *JiraConnector) InjectCredentials(req *http.Request) error {
if c.token == "" { return fmt.Errorf("no Jira API token configured") }
req.Header.Del("Authorization")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
return nil
}
func (c *JiraConnector) SetToken(token string) { c.token = token }
func (c *JiraConnector) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.atlassian.com/me", nil)
if err != nil { return err }
if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) }
resp, err := http.DefaultClient.Do(req)
if err != nil { return fmt.Errorf("Jira API unreachable: %w", err) }
defer resp.Body.Close()
if resp.StatusCode == 401 { return fmt.Errorf("Jira token is invalid or expired") }
if resp.StatusCode != 200 { return fmt.Errorf("Jira API returned %d", resp.StatusCode) }
return nil
}