Level 14 /

Connector Config

Every building needs a control panel. Gopher opens the junction box and starts labeling the switches: one for credentials, one for the API endpoint, one for each setting the operator needs. Wire each switch correctly, and the system configures itself from the command line.

Gopher says

The connector skeleton compiles, but it can’t do anything without credentials. When a user runs your connector from the command line, they pass client IDs, secrets, and API URLs. This level builds the configuration layer that parses those inputs and delivers them to your connector’s constructor as a typed struct.

In baton-junction, configuration lives in pkg/config/ and uses the SDK’s field package with a go:generate pattern to produce type-safe accessors.

Configuration Fields

The SDK’s field package provides typed field declarations with functional options. Each field becomes a CLI flag that the SDK automatically registers:

CLI flag namefunctional option: requiredfunctional option: help textfunctional option: secret
AppClientIDField = field.StringField(
    "app-client-id",
    field.WithRequired(true),
    field.WithDescription("OAuth2 client ID"),
)

AppClientSecretField = field.StringField(
    "app-client-secret",
    field.WithRequired(true),
    field.WithIsSecret(true),
    field.WithDescription("OAuth2 client secret"),
)

Field types match Go types: field.StringField, field.BoolField, field.IntField. The first argument is always the flag name in kebab-case - this becomes the --flag-name CLI flag.

Functional Options

Each field type accepts options that configure its behavior:

OptionWhat It Does
WithRequired(true)SDK exits with an error if the flag is missing
WithDescription("...")Help text shown in --help output
WithIsSecret(true)Redacts the value from logs and debug output
WithDefaultValue(v)Provides a fallback when the flag isn’t specified

Secrets like OAuth client secrets and API tokens should always use WithIsSecret(true). The SDK redacts these values from structured logs, so credentials never leak into observability pipelines.

Default SDK Fields

Before you define any connector-specific fields, the SDK already registers its own flags on every connector:

FlagPurpose
--client-idOAuth2 client ID for the C1 tenant connection
--client-secretOAuth2 client secret for the C1 tenant connection
--log-levelControls zap log verbosity (debug, info, warn, error)
--log-formatLog output format (json, console)
-f / --filePath to the sync output file (default sync.c1z)

These names are reserved. If your target app also uses OAuth2, you can’t name your field --client-id - the SDK already owns that name. That’s why baton-junction uses --app-client-id and --app-client-secret for the target app’s credentials. The app- prefix avoids the collision.

Every connector also gets -h / --help built in. Running ./baton-junction --help prints all available flags - both the SDK defaults and your custom fields - along with their descriptions and environment variable names.

Environment Variable Mapping

Every CLI flag automatically gets a corresponding environment variable. The SDK derives the variable name from the flag name: replace hyphens with underscores and prepend BATON_. The flag --app-client-id becomes BATON_APP_CLIENT_ID:

CLI FlagEnvironment Variable
--app-client-idBATON_APP_CLIENT_ID
--app-client-secretBATON_APP_CLIENT_SECRET
--base-urlBATON_BASE_URL

CLI flags take precedence over environment variables. This lets operators set defaults in their environment and override specific values on the command line.

The Config Bundle

Individual fields are bundled into a Configuration object that the SDK uses to register all flags at once:

var ConfigurationFields = []field.SchemaField{
    AppClientIDField,
    AppClientSecretField,
    BaseURLField,
}

var Config = field.NewConfiguration(
    ConfigurationFields,
    field.WithConstraints(FieldRelationships...),
    field.WithConnectorDisplayName("Baton App"),
    field.WithHelpUrl("/docs/baton/app"),
    field.WithIconUrl("/static/app-icons/baton-junction.svg"),
)

field.NewConfiguration takes the slice of fields and optional metadata. WithConnectorDisplayName sets what appears in the C1 UI. WithHelpUrl and WithIconUrl provide documentation and branding links.

Field Relationships

Sometimes fields are mutually exclusive - for example, a connector might support either API key auth or OAuth, but not both at the same time. FieldsExclusivelyRequired declares this:

var FieldRelationships = []field.SchemaFieldRelationship{
    field.FieldsExclusivelyRequired(
        ApiKeyField,
        AppClientIDField,
    ),
}

The SDK validates these constraints before calling your constructor. If both --api-key and --app-client-id are provided, the SDK exits with a clear error. In baton-junction, we don’t use exclusive relationships (all three fields are always required), but many connectors that support multiple auth methods do.

The go:generate Pattern

Declaring fields is only half the story. The connector’s New function receives a *cfg.App struct - not raw string flags. How do fields become struct fields? The go:generate pattern bridges this gap.

In config.go, a directive tells go generate to run a code generator:

//go:generate go run ./gen

The gen/gen.go file calls the SDK’s code generator:

package main

import (
    cfg "example/baton-junction/pkg/config"
    "github.com/conductorone/baton-sdk/pkg/config"
)

func main() {
    config.Generate("app", cfg.Config)
}

config.Generate takes two arguments: a name prefix (here "app", producing the struct name App) and the Configuration object. It reads the field definitions and writes conf.gen.go:

// Code generated by baton-sdk. DO NOT EDIT!!!
package config

type App struct {
    AppClientId     string `mapstructure:"app-client-id"`
    AppClientSecret string `mapstructure:"app-client-secret"`
    BaseUrl         string `mapstructure:"base-url"`
}

Each field’s kebab-case flag name becomes a mapstructure tag. The SDK uses viper under the hood to parse CLI flags and environment variables, then populates this struct via mapstructure. Your connector’s New function receives a *cfg.App with all values already typed and validated.

How Config Flows

The full lifecycle from command line to your code:

user inputSDK processingyour code
./baton-junction --app-client-id=abc --app-client-secret=xyz --base-url=https://api.example.comSDK parses flags + env vars → validates required/constraints → populates *cfg.Appconnector.New(ctx, cc *cfg.App, opts) → client.New(ctx, cc.AppClientId, cc.AppClientSecret, cc.BaseUrl)

The SDK does all the parsing and validation. Your New function just reads struct fields. If a required field is missing, the SDK exits with a helpful error message before your code ever runs.

Running Modes

The SDK default fields --client-id and --client-secret control how the connector runs:

One-shot mode - Omit --client-id and --client-secret. The connector runs a single sync against the target app, writes the results to a local sync.c1z file, and exits. This is the primary workflow during development. We’ll cover how to inspect and validate that output in Level 19.

Service mode - Provide --client-id and --client-secret from your C1 tenant. The connector runs continuously, polling the tenant for sync tasks, provisioning requests, and action invocations. After each sync completes, the tenant tells the connector how long to wait before the next cycle (default one hour). The tenant never calls a self-hosted connector - the connector always initiates outbound.

During development, --log-level debug and --log-format console are useful for seeing verbose, human-readable zap output. In service mode, the defaults (--log-level info, --log-format json) are appropriate for structured log pipelines.

Run the code generator that produces conf.gen.go:

Define three configuration field variables: AppClientIDField (flag "app-client-id", required), AppClientSecretField (flag "app-client-secret", required, marked as a secret), and BaseURLField (flag "base-url", required). Then bundle them into a Config variable.

Gopher says

The configuration layer is wired: fields declare what the connector needs, go:generate produces a typed struct, and the SDK handles parsing and validation. Your connector’s New function receives clean, typed values without touching a single flag parser.

Next up: building the resource syncers that turn API data into SDK types.

Explore the related connector code files

Next Lesson

Syncing Resources

Continue