Skip to content

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.

Terminal window
mkdir providers/acme
touch providers/acme/acme.go

Every provider must implement the provider.Provider interface:

provider/provider.go
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
}

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.

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:

TypeBehaviour
FieldTypeStringFree-text input
FieldTypeBoolInline toggle
FieldTypeStringListComma-separated input stored as a string
FieldTypeTextMulti-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"

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.

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"
)
Terminal window
make build
./build/keyledger

Open the Providers screen (p), navigate to Acme, enable it, enter your API key, and press r to refresh.

For CI testing:

Terminal window
ACME_API_KEY=your-key ./build/keyledger list --provider acme

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
}
  • init() calls provider.Register
  • Name() returns a unique, lowercase, hyphen-safe identifier
  • ListKeys returns a descriptive error when credentials are missing
  • ListKeys handles 401 explicitly (session expired / invalid key)
  • DefaultPatterns returns at least one regex pattern
  • Credential field names added to internal/keyring/providers.go
  • Blank import added to main.go
  • make build && make lint passes