All articles
tutorialgoconnectors2026-02-0912 min read

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:

  • Structured action typesjira.issue.create instead of POST api.atlassian.com
  • Rich parameters — extract project key, issue type, assignee from the request body
  • Risk classification — mark destructive operations as high-risk
  • Credential injection — the proxy injects API tokens from the encrypted vault
  • Domain matching — route requests to the right connector automatically
  • The 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:
  • Always include a fallback case — agents will call endpoints you didn't anticipate
  • Extract meaningful parameters (issue keys, project IDs, user names) for policy conditions
  • Set RiskLevel based on the operation: reads are low, writes are medium, deletes are high
  • Use the dot-notation convention: service.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:

  • Block issue deletion: scope jira.issue.delete → deny
  • Require approval for bulk updates: scope jira.issue.update + rule parameters.bulk equals true → require_approval
  • Allow reads for all gateways: scope jira.issue.get → allow
  • Policies 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
    }
    

    What's next

  • Browse the GitHub connector source for a production-grade reference with 20+ action types
  • Read the policy docs to write rules targeting your custom action types
  • Join the GitHub Discussions to share your connector or request built-in support for an API
  • Building a Custom TameFlare Connector in Go | TameFlare