Skip to main content
You are building a Go module that implements the ResourceSyncer contract. The interface is focused: four methods per resource type, and the SDK handles everything else. Your connector answers three questions:
  1. What exists? Users, groups, roles, applications
  2. What permissions are available? Entitlements that can be granted
  3. Who has what? Grants connecting users to permissions
The Baton SDK handles orchestration, output format, pagination coordination, and communication with ConductorOne. You focus on translating your system’s API into the Resource/Entitlement/Grant model.

Project structure

Directory layout

A common structure for Baton connectors:
baton-{service}/
  cmd/baton-{service}/
    main.go                 # Entry point, config setup
  pkg/
    config/
      config.go             # Configuration fields (API keys, URLs, etc.)
    connector/
      connector.go          # Register resource types with the SDK
      users.go              # User resource builder
      groups.go             # Group resource builder
      roles.go              # Role resource builder (if applicable)
      resource_types.go     # Shared resource type definitions
    client/                 # Optional: API wrapper
      client.go             # HTTP client for target system API
  .github/workflows/        # CI/release automation
    ci.yaml                 # Build, lint, test on PRs
    release.yaml            # Build and publish releases
  .golangci.yml             # Lint configuration
  baton_capabilities.json   # Capability manifest (what operations are supported)
  go.mod                    # Dependencies (includes baton-sdk)
  go.sum                    # Dependency checksums
  Makefile                  # Build targets
  README.md                 # Usage documentation
  LICENSE                   # Apache 2.0 (standard for Baton)
Not all connectors follow this exact structure. Some organize code differently based on their needs. The structure above is a common starting point, not a requirement.
The naming convention is baton-{service} - for example, baton-github, baton-okta, baton-salesforce.

Key files

FilePurpose
cmd/.../main.goEntry point. Parses config, creates connector, runs CLI
pkg/connector/connector.goRegisters all resource builders with the SDK
pkg/connector/*.goOne file per resource type implementing ResourceSyncer
pkg/client/client.goWraps target system API with Go methods
baton_capabilities.jsonDeclares what operations (sync, grant, revoke) are supported

Makefile targets

Standard connectors include these make targets:
# Build the connector binary
make build
# Output: dist/{os}_{arch}/baton-{service}

# Run golangci-lint
make lint

# Update dependencies
make update-deps

Setting up a new connector

1

Create repository

mkdir baton-yourservice
cd baton-yourservice
go mod init github.com/your-org/baton-yourservice
2

Add baton-sdk dependency

go get github.com/conductorone/baton-sdk
3

Create directory structure

mkdir -p cmd/baton-yourservice pkg/connector pkg/client
4

Copy standard files

From an existing connector:
  • .golangci.yml (lint configuration)
  • Makefile (build targets)
  • .github/workflows/ci.yaml (CI workflow)
  • .github/workflows/release.yaml (release workflow)
5

Implement the connector

Following the patterns in this guide

Capability manifest

The capability manifest declares what operations your connector supports. This file is auto-generated by running:
./dist/*/baton-yourservice capabilities > baton_capabilities.json
Example manifest:
{
  "@type": "type.googleapis.com/c1.connector.v2.ConnectorCapabilities",
  "resourceTypeCapabilities": [
    {
      "resourceType": {
        "id": "user",
        "displayName": "User",
        "traits": ["TRAIT_USER"]
      },
      "capabilities": ["CAPABILITY_SYNC"]
    },
    {
      "resourceType": {
        "id": "group",
        "displayName": "Group",
        "traits": ["TRAIT_GROUP"]
      },
      "capabilities": ["CAPABILITY_SYNC", "CAPABILITY_PROVISION"]
    }
  ]
}
CapabilityMeaning
CAPABILITY_SYNCResource type participates in sync
CAPABILITY_TARGETED_SYNCSupports fetching specific resources by ID
CAPABILITY_PROVISIONSupports Grant/Revoke operations
Do not write this file manually. Always generate it from the connector binary to ensure accuracy.

Implementing ResourceSyncer

The ResourceSyncer interface is the heart of your connector. Per resource type, implement four methods:
  • ResourceType(ctx) *v2.ResourceType
  • List(ctx, parentResourceID, token) ([]*v2.Resource, nextToken, annotations, error)
  • Entitlements(ctx, resource, token) ([]*v2.Entitlement, nextToken, annotations, error)
  • Grants(ctx, resource, token) ([]*v2.Grant, nextToken, annotations, error)

ResourceType()

Defines what this resource is:
func (u *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType {
    return &v2.ResourceType{
        Id:          "user",
        DisplayName: "User",
        Traits:      []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER},
    }
}
Traits tell ConductorOne how to interpret the resource. Use TRAIT_USER for people, TRAIT_GROUP for collections, TRAIT_ROLE for permission bundles.

List()

Fetches all instances of this resource type:
func (u *userBuilder) List(ctx context.Context, parentID *v2.ResourceId,
    pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) {

    // Call your API
    users, nextPage, err := u.client.GetUsers(ctx, pToken.Token)
    if err != nil {
        return nil, "", nil, err
    }

    // Convert to Baton resources
    var resources []*v2.Resource
    for _, user := range users {
        r, err := resource.NewUserResource(user.Name, userResourceType, user.ID,
            resource.WithEmail(user.Email, true))
        if err != nil {
            return nil, "", nil, err
        }
        resources = append(resources, r)
    }

    return resources, nextPage, nil, nil
}
Return a page of resources plus a token for the next page. Empty token means you’re done. The SDK calls you repeatedly until you return an empty token.

The RawId annotation

Always include a RawId annotation with the external system’s stable identifier:
r, err := resource.NewUserResource(user.Name, userResourceType, user.ID,
    resource.WithEmail(user.Email, true))
if err != nil {
    return nil, "", nil, err
}
// Add the external system's ID for correlation
r.WithAnnotation(&v2.RawId{Id: user.ID})
Why this matters: ConductorOne uses the RawId to:
  • Correlate resources across syncs - Same ID = same resource, not a duplicate
  • Track provenance - Know which connector discovered which resource
  • Enable pre-sync patterns - Support reservation mechanisms that create placeholders before sync
SystemRawId valueExample
Oktaapp.Id0oa1xyz789abcdef0
AWSARNarn:aws:iam::123456789:user/alice
GCPResource nameprojects/my-project-123
Azure ADObject ID550e8400-e29b-41d4-a716-446655440000
GitHubNode ID or numeric IDMDQ6VXNlcjE= or 12345

Entitlements()

Defines what permissions this resource offers:
func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource,
    pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) {

    // Groups typically offer membership
    membership := entitlement.NewAssignmentEntitlement(resource, "member",
        entitlement.WithDisplayName("Member"),
        entitlement.WithDescription("Member of this group"))

    return []*v2.Entitlement{membership}, "", nil, nil
}
Users typically return empty here - they receive grants, they don’t offer entitlements.

Grants()

Reports who has each entitlement:
func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource,
    pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {

    // Get group members from API
    members, nextPage, err := g.client.GetGroupMembers(ctx, resource.Id.Resource, pToken.Token)
    if err != nil {
        return nil, "", nil, err
    }

    var grants []*v2.Grant
    for _, member := range members {
        g := grant.NewGrant(resource, "member",
            &v2.ResourceId{ResourceType: "user", Resource: member.ID})
        grants = append(grants, g)
    }

    return grants, nextPage, nil, nil
}
Pagination must progress: the SDK detects and errors if your “next page token” repeats the input token.

Modeling decisions

How you structure resources and entitlements determines what ConductorOne can manage.

What to sync as a resource?

Good candidatesWhy
UsersPeople who have access
GroupsCollections that grant access
RolesPermission bundles
TeamsOrganizational units with permissions
Projects/WorkspacesScoped containers
Skip theseWhy
Business dataCustomers, orders, tickets - not access control
Logs/eventsOperational data, not identity
ConfigurationsUnless they control who can do what

Entitlement granularity

Fine-grained: Separate entitlements for read, write, admin
  • Pro: More control in access reviews
  • Con: More complexity, more grants to manage
Coarse-grained: Single “access” entitlement
  • Pro: Simpler model
  • Con: Can’t revoke admin without revoking everything
Choose based on how access decisions are made. If “can this person admin the database?” is a real question, admin should be a separate entitlement.

Parent-child relationships

Some systems have hierarchies: organization -> project -> resource.
// Parent declares it has children
orgResource, _ := resource.NewResource("Acme Corp", orgType, "org-123",
    resource.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: "project"}))

// Child references parent
projectResource, _ := resource.NewResource("Platform", projectType, "proj-456",
    resource.WithParentResourceID(orgResource.Id))
Use hierarchies when:
  • Child resources only make sense within a parent context
  • You need to scope List() calls to a parent
  • The target API is organized hierarchically
See Pagination patterns for handling hierarchical data with nested pagination.

Definition of done

Your connector is ready when:
  • Sync works deterministically (same inputs produce stable IDs and consistent results across runs)
  • Pagination works (no token loops; handles large datasets)
  • You can run without production ConductorOne credentials (local testing story exists)

Build and test

make build
./dist/baton-yourservice --api-key $KEY --log-level debug

# Inspect results
baton resources -f sync.c1z
baton grants -f sync.c1z

Common mistakes

Resource type mismatches

A grant references a principal by ResourceId (type + id). If your principal type id doesn’t match what you used in ResourceType(), you will create dangling edges.

Implicit capability claims

A connector may have a --provisioning flag but still not implement specific provisioners. Treat “flag exists” as necessary, not sufficient.

API clients

If the service has an official Go SDK, use it. Otherwise, the SDK’s uhttp package handles rate limiting and retries:
import "github.com/conductorone/baton-sdk/pkg/uhttp"

httpClient, _ := uhttp.NewBaseHttpClient(ctx)

Error handling

Return errors, don’t panic. Wrap errors with context:
if err != nil {
    return nil, "", nil, fmt.Errorf("baton-yourservice: failed to list users: %w", err)
}

Credentials

Never log credentials. Use the SDK’s SecretString type for sensitive config fields:
type Config struct {
    APIKey types.SecretString `mapstructure:"api-key"`
}

Next steps

Quick reference

Resource traits

TraitUse for
TRAIT_USERIndividual accounts
TRAIT_GROUPCollections of users
TRAIT_ROLEPermission bundles
TRAIT_APPApplications

Method return signatures

// List returns: resources, nextPageToken, annotations, error
([]*v2.Resource, string, annotations.Annotations, error)

// Entitlements returns: entitlements, nextPageToken, annotations, error
([]*v2.Entitlement, string, annotations.Annotations, error)

// Grants returns: grants, nextPageToken, annotations, error
([]*v2.Grant, string, annotations.Annotations, error)

SDK helpers

// Create user resource
resource.NewUserResource(name, resourceType, id, ...options)

// Create group resource
resource.NewGroupResource(name, resourceType, id, ...options)

// Create entitlement
entitlement.NewAssignmentEntitlement(resource, slug, ...options)

// Create grant
grant.NewGrant(resource, entitlementSlug, principalID)