Gopher says
Sync reads data. Provisioning changes memberships. Actions let the platform trigger any operation on your connector: enabling a user account, disabling it, updating a profile, or anything else your target system supports.
In baton-junction, global actions like enable/disable live on the Connector
struct, while resource-scoped actions like profile updates live on the
userBuilder. Both use the same ActionRegistry pattern.
Two Kinds of Actions
The SDK provides two interfaces for registering actions:
| Global Actions | Resource-Scoped Actions | |
|---|---|---|
| Interface | GlobalActionProvider | ResourceActionProvider |
| Registered on | Connector struct | Builder struct (e.g., userBuilder) |
| Method | GlobalActions(ctx, registry) | ResourceActions(ctx, registry) |
The ActionRegistry
Both GlobalActions and ResourceActions receive an actions.ActionRegistry. You register actions by providing a schema (what the action looks like) and a handler (what it does):
func (m *Connector) GlobalActions(ctx context.Context, registry actions.ActionRegistry) error {
return registry.Register(ctx, schema, m.handler)
}
The SDK calls this method during connector startup and registers all actions with the platform. If a builder doesn’t implement ResourceActionProvider, the SDK skips it silently.
BatonActionSchema
Every action needs a schema that describes its name, arguments, return types, and action type:
enableSchema := &v2.BatonActionSchema{
Name: "enable_user",
DisplayName: "Enable User",
Description: "Enable a user account in the target app",
Arguments: []*config.Field{
{Name: "resource_id", DisplayName: "User Resource ID",
Field: &config.Field_StringField{}, IsRequired: true},
},
ReturnTypes: []*config.Field{
{Name: "success", DisplayName: "Success", Field: &config.Field_BoolField{}},
},
ActionType: []v2.ActionType{
v2.ActionType_ACTION_TYPE_ACCOUNT,
v2.ActionType_ACTION_TYPE_ACCOUNT_ENABLE,
},
} The ActionType field tells the platform what kind of operation this is. Common values:
| ActionType | Use For |
|---|---|
ACTION_TYPE_ACCOUNT_ENABLE | Activating a disabled account |
ACTION_TYPE_ACCOUNT_DISABLE | Suspending an account |
ACTION_TYPE_ACCOUNT_UPDATE_PROFILE | Changing profile attributes |
ACTION_TYPE_RESOURCE_CREATE | Creating a new resource |
Global Actions: Enable & Disable Accounts
In baton-junction, enable and disable are global actions on the Connector struct. Both delegate to a shared setUserStatus helper:
func (m *Connector) enableUser(ctx context.Context, args *structpb.Struct) (
*structpb.Struct, annotations.Annotations, error) {
return m.setUserStatus(ctx, args, "active", "enable-user")
}
func (m *Connector) disableUser(ctx context.Context, args *structpb.Struct) (
*structpb.Struct, annotations.Annotations, error) {
return m.setUserStatus(ctx, args, "inactive", "disable-user")
}
The shared helper extracts the user ID, calls the API, and returns a result:
func (m *Connector) setUserStatus(
ctx context.Context, args *structpb.Struct, status, actionName string,
) (*structpb.Struct, annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
userID, ok := actions.GetStringArg(args, "resource_id")
if !ok || userID == "" {
return nil, nil, fmt.Errorf("baton-junction: %s: missing resource_id", actionName)
}
updated, err := m.client.UpdateUser(ctx, userID, map[string]string{"status": status})
if err != nil {
l.Error(fmt.Sprintf("baton-junction: %s: failed to update user status", actionName),
zap.Error(err), zap.String("user_id", userID))
return nil, nil, fmt.Errorf("baton-junction: %s: %w", actionName, err)
}
l.Info(fmt.Sprintf("baton-junction: %s: success", actionName),
zap.String("user_id", userID), zap.String("new_status", updated.Status))
result := &structpb.Struct{
Fields: map[string]*structpb.Value{
"success": {Kind: &structpb.Value_BoolValue{BoolValue: true}},
},
}
return result, nil, nil
}
The SDK’s actions.GetStringArg helper extracts typed values from the structpb.Struct arguments - no manual type assertions needed.
Action Handler Signature
Every action handler has the same signature:
func(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error)
Arguments come in as *structpb.Struct (a generic protobuf map), and results go out the same way. The SDK provides helpers to extract typed values:
| Helper | Returns | Use For |
|---|---|---|
actions.GetStringArg(args, "name") | (string, bool) | Free-form string inputs |
actions.GetResourceIDArg(args, "name") | (*v2.ResourceId, bool) | Resource reference inputs |
Resource-Scoped Actions: Update Profile
Resource-scoped actions are registered on a builder. In baton-junction, the user builder registers “Update User Profile”:
func (o *userBuilder) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error {
updateProfileSchema := &v2.BatonActionSchema{
Name: "update_profile",
DisplayName: "Update User Profile",
Description: "Update a user's profile attributes",
ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_ACCOUNT_UPDATE_PROFILE},
Arguments: []*config.Field{
{
Name: "user_id",
IsRequired: true,
Field: &config.Field_ResourceIdField{
ResourceIdField: &config.ResourceIdField{
Rules: &config.ResourceIDRules{
AllowedResourceTypeIds: []string{"user"},
},
},
},
},
{Name: "first_name", Field: &config.Field_StringField{}},
{Name: "last_name", Field: &config.Field_StringField{}},
{Name: "email", Field: &config.Field_StringField{}},
{Name: "department", Field: &config.Field_StringField{}},
},
}
return registry.Register(ctx, updateProfileSchema, o.updateUserProfile)
}
Two important differences from global actions:
-
The receiver is
*userBuilder, not*Connector. The SDK detects that the user builder implementsResourceActionProvidervia type assertion - just like it detectsResourceProvisionerfor Grant/Revoke. If a builder doesn’t implementResourceActions, the SDK skips it. -
The
user_idargument usesField_ResourceIdFieldinstead ofField_StringField. This tells C1 the input is a resource reference, not a free-form string.AllowedResourceTypeIdsconstrains it to user resources, so the platform shows a user picker instead of a text box.
The Handler
The handler uses actions.GetResourceIDArg instead of actions.GetStringArg to extract the typed resource reference:
func (o *userBuilder) updateUserProfile(ctx context.Context, args *structpb.Struct) (
*structpb.Struct, annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
userResourceID, ok := actions.GetResourceIDArg(args, "user_id")
if !ok {
return nil, nil, fmt.Errorf("baton-junction: update-user-profile: missing required argument user_id")
}
userID := userResourceID.GetResource()
attrs := make(map[string]string)
for c1Name, apiName := range attrsLookup {
val, found := actions.GetStringArg(args, c1Name)
if found && val != "" {
attrs[apiName] = val
}
}
updated, err := o.client.UpdateUser(ctx, userID, attrs)
if err != nil {
l.Error("baton-junction: update-user-profile: failed to update user",
zap.Error(err), zap.String("user_id", userID))
return nil, nil, fmt.Errorf("baton-junction: update-user-profile: %w", err)
}
l.Info("baton-junction: update-user-profile: success",
zap.String("user_id", userID), zap.Int("attrs_updated", len(attrs)))
result := &structpb.Struct{
Fields: map[string]*structpb.Value{
"success": {Kind: &structpb.Value_BoolValue{BoolValue: true}},
"user_id": {Kind: &structpb.Value_StringValue{StringValue: updated.ID}},
},
}
return result, nil, nil
}
The attrsLookup map translates C1 attribute names to the target app’s API field names. Only attributes the user actually provided get sent in the update - this prevents accidentally blanking fields.
Older connectors may use RegisterActionManagerLimited and
CustomActionManager to register actions. These interfaces still work but are
deprecated. New connectors should use GlobalActionProvider and
ResourceActionProvider as shown here. The key difference: the new interfaces
separate global vs resource-scoped actions cleanly, while the old interface
lumped everything together.
Gopher says
You’ve built a complete connector! Sync reads data, provisioning changes
memberships, and actions handle everything else. From go mod init to
enable/disable/update-profile - every file, every pattern, every interface.
In baton-junction, every technique you just used mirrors real baton connector code. The entire Baton ecosystem is now open to you.
Next: integration testing to verify everything works together!
Next Lesson
Standalone Test Server

