Adding a Provider
This guide walks through building a fully functional provider from scratch. We’ll use a fictional provider called Acme as the example.
1. Create the package
Section titled “1. Create the package”mkdir providers/acmetouch providers/acme/acme.go2. Implement the Provider interface
Section titled “2. Implement the Provider interface”Every provider must implement the provider.Provider interface:
type Provider interface { Name() string DisplayName() string ListKeys(ctx context.Context, cfg ProviderConfig) (*KeyInventory, error) DefaultPatterns() []KeyPattern}Minimal skeleton:
package acme
import ( "context" "encoding/json" "fmt" "net/http" "time"
"github.com/riptideslabs/keyledger/provider")
const baseURL = "https://api.acme.example/v1"
func init() { provider.Register(&Acme{}) }
type Acme struct{}
func (p *Acme) Name() string { return "acme" }func (p *Acme) DisplayName() string { return "Acme" }
func (p *Acme) DefaultPatterns() []provider.KeyPattern { return []provider.KeyPattern{ { ID: "acme-api-key", Description: "Acme API Key", Regex: `acme_[a-zA-Z0-9]{40}`, }, }}
func (p *Acme) ListKeys(ctx context.Context, cfg provider.ProviderConfig) (*provider.KeyInventory, error) { apiKey := cfg.Credentials["api_key"] if apiKey == "" { return nil, fmt.Errorf("acme: api_key not set — open Providers and enter your API key") }
keys, err := fetchKeys(ctx, apiKey) if err != nil { return nil, err }
return &provider.KeyInventory{Provider: "acme", Keys: keys}, nil}ListKeys in detail
Section titled “ListKeys in detail”cfg.Credentials is a map[string]string populated from the encrypted credential store at call time. The key names come from the credential fields you register in step 4.
cfg.RawJSON contains the provider’s JSON config block from the database — unmarshal it to read user-configured options.
Return *provider.KeyInventory with the provider name and the slice of keys. Populate as many fields as the API provides:
provider.Key{ ID: r.ID, Name: r.Name, PartialHint: r.MaskedKey, // e.g. "sk-****abc" Status: r.Status, // "active" | "inactive" | "disabled" CreatedAt: r.CreatedAt, // *time.Time LastUsedAt: r.LastUsedAt, // *time.Time — nil if not available ExpiresAt: r.ExpiresAt, // *time.Time — nil if no expiry CreatedBy: &provider.Actor{ ID: r.CreatorID, Name: r.CreatorName, Email: r.CreatorEmail, }, Scope: provider.KeyScope{ WorkspaceID: r.WorkspaceID, WorkspaceName: r.WorkspaceName, // or ProjectID, ProjectName, IAMUser, etc. },}Leave fields nil/empty when the API does not provide them. KeyLedger handles nil safely throughout.
3. Optional interfaces
Section titled “3. Optional interfaces”Configurable — expose config fields in the TUI
Section titled “Configurable — expose config fields in the TUI”Implement provider.Configurable to add user-editable settings to the TUI Providers screen:
func (p *Acme) ConfigFields() []provider.ConfigField { return []provider.ConfigField{ { Key: "workspace_id", Label: "Workspace ID", Description: "Limit listing to a single workspace", Type: provider.FieldTypeString, Default: "", }, { Key: "include_inactive", Label: "Include inactive keys", Type: provider.FieldTypeBool, Default: "false", }, }}Read config values in ListKeys:
var cfg acmeConfig_ = json.Unmarshal(providerCfg.RawJSON, &cfg)
type acmeConfig struct { WorkspaceID string `json:"workspace_id"` IncludeInactive bool `json:"include_inactive"`}Available field types:
| Type | Behaviour |
|---|---|
FieldTypeString | Free-text input |
FieldTypeBool | Inline toggle |
FieldTypeStringList | Comma-separated input stored as a string |
FieldTypeText | Multi-line text (e.g. JSON) |
LoginProvider — interactive session login (Ory Kratos)
Section titled “LoginProvider — interactive session login (Ory Kratos)”Implement provider.LoginProvider instead of using a static API key when the provider authenticates via an Ory Kratos browser flow (like Mistral):
func (p *Acme) LoginConfig() provider.LoginConfig { return provider.LoginConfig{ KratosBaseURL: "https://auth.acme.example", // Kratos API base AdminBaseURL: "https://admin.acme.example", // passed as return_to }}The TUI automatically shows a Login action in the provider detail screen. The Kratos login flow (2-step browser flow with cookie jar) is handled entirely by internal/kratos/login.go. The resulting session cookie is stored as session_token in the encrypted credential store.
In ListKeys, read the cookie and attach it as a Cookie header:
token := cfg.Credentials["session_token"]req.Header.Set("Cookie", token) // stored as "name=value"4. Register the credential field names
Section titled “4. Register the credential field names”Add the provider’s credential fields to internal/keyring/providers.go:
var ProviderCredentials = map[string][]string{ // existing providers… "acme": {"api_key"},}And optionally map environment variables for auto-discovery:
var ProviderEnvVars = map[string]map[string][]string{ // existing providers… "acme": { "api_key": {"ACME_API_KEY"}, },}When the listed env vars are set, KeyLedger presents them as auto-discovery candidates in the TUI credential picker.
5. Blank-import in main.go
Section titled “5. Blank-import in main.go”The init() function in your package calls provider.Register, but Go only runs init() if the package is imported. Add a blank import to main.go:
import ( // existing providers… _ "github.com/riptideslabs/keyledger/providers/acme")6. Build and test
Section titled “6. Build and test”make build./build/keyledgerOpen the Providers screen (p), navigate to Acme, enable it, enter your API key, and press r to refresh.
For CI testing:
ACME_API_KEY=your-key ./build/keyledger list --provider acmeComplete example
Section titled “Complete example”Below is a minimal but complete provider that fetches keys from a fictional JSON API:
package acme
import ( "context" "encoding/json" "fmt" "net/http" "time"
"github.com/riptideslabs/keyledger/provider")
const baseURL = "https://api.acme.example/v1"
func init() { provider.Register(&Acme{}) }
type Acme struct{}
func (p *Acme) Name() string { return "acme" }func (p *Acme) DisplayName() string { return "Acme" }
func (p *Acme) DefaultPatterns() []provider.KeyPattern { return []provider.KeyPattern{ {ID: "acme-api-key", Description: "Acme API Key", Regex: `acme_[a-zA-Z0-9]{40}`}, }}
func (p *Acme) ListKeys(ctx context.Context, cfg provider.ProviderConfig) (*provider.KeyInventory, error) { apiKey := cfg.Credentials["api_key"] if apiKey == "" { return nil, fmt.Errorf("acme: api_key not set") }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/keys", nil) if err != nil { return nil, fmt.Errorf("acme: %w", err) } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, fmt.Errorf("acme: %w", err) } defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized { return nil, fmt.Errorf("acme: invalid API key") } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("acme: API returned %d", resp.StatusCode) }
var body struct { Keys []struct { ID string `json:"id"` Name string `json:"name"` Hint string `json:"hint"` Status string `json:"status"` CreatedAt *time.Time `json:"created_at"` } `json:"keys"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, fmt.Errorf("acme: decode response: %w", err) }
keys := make([]provider.Key, 0, len(body.Keys)) for _, r := range body.Keys { name := r.Name if name == "" { name = r.ID } keys = append(keys, provider.Key{ ID: r.ID, Name: name, PartialHint: r.Hint, Status: r.Status, CreatedAt: r.CreatedAt, }) }
return &provider.KeyInventory{Provider: "acme", Keys: keys}, nil}Checklist
Section titled “Checklist”-
init()callsprovider.Register -
Name()returns a unique, lowercase, hyphen-safe identifier -
ListKeysreturns a descriptive error when credentials are missing -
ListKeyshandles 401 explicitly (session expired / invalid key) -
DefaultPatternsreturns at least one regex pattern - Credential field names added to
internal/keyring/providers.go - Blank import added to
main.go -
make build && make lintpasses