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:
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:
| Option | What 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:
| Flag | Purpose |
|---|---|
--client-id | OAuth2 client ID for the C1 tenant connection |
--client-secret | OAuth2 client secret for the C1 tenant connection |
--log-level | Controls zap log verbosity (debug, info, warn, error) |
--log-format | Log output format (json, console) |
-f / --file | Path 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 Flag | Environment Variable |
|---|---|
--app-client-id | BATON_APP_CLIENT_ID |
--app-client-secret | BATON_APP_CLIENT_SECRET |
--base-url | BATON_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.
The generated App struct also includes typed accessor methods like
GetString, GetBool, GetInt, and GetStringSlice. These use reflection
to find fields by their mapstructure tag. While you’ll typically access
struct fields directly (cc.AppClientId), the accessors are useful when you
need dynamic field lookup by name.
How Config Flows
The full lifecycle from command line to your code:
./baton-junction --app-client-id=abc --app-client-secret=xyz --base-url=https://api.example.com
↓
SDK parses flags + env vars → validates required/constraints → populates *cfg.App
↓
connector.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.
Next Lesson
Syncing Resources

