Connectors

Building an Action Provider

Author a provider with ProviderSpec and RegisterFromSpec, write an action's Execute function, declare input/output schemas, and wire it via fx.

Adding a connector is a matter of describing a provider and its actions, then registering it — no changes to the core engine. This page shows the declarative path (ProviderSpec + RegisterFromSpec), how to author an action, and how the generated catalog is produced.

The two authoring paths

There are two ways a provider enters the registry, both funneling through the same Registrar:

  • Declarative / generated — most catalog providers are described in a bootstrap manifest and their registration code is generated. Good for HTTP APIs that fit the standard shape.
  • Hand-written Go — a small Register function for providers that need custom logic (in-process computation, bespoke webhook handling). This is the path you'll use for a one-off provider in your own build.

Both call providerkit.RegisterFromSpec (infrastructure/connector/providerkit/register_from_spec.go).

A minimal provider

Here is a complete crypto provider example. It runs in process and does not require authentication:

func Register(r appconnector.Registrar, _ *Client) error {
	return providerkit.RegisterFromSpec(r, nil, appconnector.ProviderSpec{
		Meta: dc.Provider{
			Key:          "crypto",
			DisplayName:  "Crypto",
			Description:  "Generate random passwords, hash text, sign with HMAC, ...",
			DocsURL:      docsURL,
			Categories:   []dc.Category{dc.CategoryDevTools},
			Version:      "0.1.0",
			AuthTypes:    []dc.AuthType{dc.AuthNone},
			Publisher:    identity.ConnectorProviderKey,
			ReleaseStage: dc.ReleaseStageBeta,
		},
		BaseURL: "",
		Auth:    nil,
		Hooks: dc.ProviderHooks{
			TestAuth: func(_ context.Context, _ *dc.Connection) error { return nil },
		},
		Actions: []appconnector.ActionRegistration{
			{Spec: cryptoactions.HashTextAction, Execute: cryptoactions.ExecuteHashText},
			{Spec: cryptoactions.HMACSignatureAction, Execute: cryptoactions.ExecuteHMACSignature},
			// ... more actions
		},
	})
}

That's the whole contract: a Meta describing the provider, an optional Auth, optional Hooks, and a list of ActionRegistrations pairing each ActionSpec with its Execute function.

The ProviderSpec

ProviderSpec (application/connector/provider_spec.go) is the declarative input to RegisterFromSpec:

type ProviderSpec struct {
	Meta             dc.Provider          // ActionKeys/TriggerKeys left empty; derived at register
	BaseURL          string               // API root, may contain ${...} templates
	Auth             *APIKeyAuth          // nil for OAuth2 / no-auth
	Actions          []ActionRegistration
	Triggers         []TriggerRegistration
	Models           []dc.ModelSpec
	Mappers          []dc.MapperSpec
	ObjectCatalogs   []dc.ObjectCatalogDeclaration
	CatalogDiscovery []dc.CatalogDiscoveryDeclaration
	Hooks            dc.ProviderHooks     // TestAuth synthesized from Auth when nil
	Verification     *VerificationSpec    // replaces the synthetic GET {BaseURL}/me probe
	ProxyHeaders     map[string]string    // provider-default headers for generic_api_call
	ProxyQuery       map[string]string
}

type APIKeyAuth struct {
	PrimaryKey string   // credential key, e.g. "apiKey" or dc.CredKeyAccessToken
	ExtraKeys  []string // additional required credential keys
}

type VerificationSpec struct {
	Method          string            // default "GET"
	Endpoints       []string          // first 2xx = pass
	Headers         map[string]string // static, non-secret only
	BaseURLOverride string
}

RegisterFromSpec does several things for you:

  • Validates the spec against the provider policy.
  • Appends a generic_api_call action automatically when BaseURL != "", so an HTTP provider gets a raw pass-through action even if you declare no explicit actions.
  • Derives the fully-qualified ActionKeys / TriggerKeys / ModelKeys / MapperKeys from the registered specs.
  • Synthesizes a TestAuth hook from Auth or Verification when you don't supply one, so "test connection" works out of the box.

An HTTP provider can be nearly zero-code

Set BaseURL and an Auth key and you have a working provider with a generic_api_call action and a synthesized auth test — no Execute functions required. Add named ActionRegistrations only for the operations you want to expose as first-class, schema-validated actions.

Authoring an action

An action pairs an ActionSpec (the description) with an ActionExecuteFunc (the work). Here is the crypto.hash_text action (infrastructure/connector/providers/crypto/actions/hash_text.go):

var hashTextOutputSchema = json.RawMessage(`{"type":"string"}`)

var HashTextAction = dc.ActionSpec{
	Key:                  "crypto.hash_text",
	Provider:             "crypto",
	DisplayName:          "Text to Hash",
	Description:          "Converts text to a hash value using the chosen algorithm.",
	Auth:                 dc.AuthNone,
	InProcess:            true,
	DefaultTimeout:       5 * time.Second,
	DefaultFailurePolicy: dc.FailureFail,
	OutputSchema:         hashTextOutputSchema,
	Properties: []dc.Property{
		{
			Name: "method", Label: "Method", Type: dc.PropStaticDropdown, Required: true,
			StaticOptions: []dc.PropertyOption{
				{Label: "MD5", Value: "md5"},
				{Label: "SHA256", Value: "sha256"},
			},
		},
		{Name: "text", Label: "Text", Type: dc.PropShortText, Required: true},
	},
	Annotations: dc.ActionAnnotations{ReadOnly: true, Idempotent: true},
}

func ExecuteHashText(_ context.Context, in dc.ActionInput) (dc.ActionOutput, error) {
	method := stringArg(in.Args, "method")
	text := rawStringArg(in.Args, "text")
	// ... compute the digest ...
	raw, _ := json.Marshal(digest)
	return dc.ActionOutput{Result: raw}, nil
}

An Execute reads typed arguments from in.Args, uses in.Auth for credentials (nil here since auth is none), and returns an ActionOutput. For actions that mutate downstream state, return an EffectPatch and set the action's Semantics so the engine knows the effect class, idempotency kind, and retry safety (see Architecture).

Set semantics on anything that writes

A read action can leave semantics at their defaults, but a create, update, delete, or send action must declare its ActionSemantics. The registry validates them at startup — a not-idempotent action marked safe_retry, or a destructive action without an approval requirement, fails registration rather than silently double-writing in production.

Declaring input and output schemas

Input is authored as []Property (domain/connector/property.go), not raw JSON Schema — Ductor renders the JSON Schema (Draft 2020-12) for you via RenderActionSchema. A Property has a Name, Label, Type, Required, optional StaticOptions or a DynamicResolver, nested Properties for objects and arrays, and a Validation block (min/max length, pattern, enum, numeric bounds).

PropertyType has 19 values, including short_text, long_text, number, checkbox, datetime, json, object, array, static_dropdown, dropdown (dynamic), multi_select, secret, markdown, and connection. Widget hints are emitted under x-ductor-* schema extensions so a generic JSON Schema validator ignores them.

The OutputSchema is passed through verbatim as a raw json.RawMessage — you write the JSON Schema for the result yourself.

Registering via fx

A hand-written provider is wired by invoking its Register in the fx graph. Generated providers are wired automatically. If you're adding a provider to your own build, add an fx.Invoke(yourprovider.Register) (and an fx.Provide for its client if it has one) alongside the connector registry module.

The generated catalog

The bulk of the catalog is code-generated from a declarative manifest — you won't usually write this, but understanding it helps when reading the catalog source. The manifest (cmd/ductor/connector_bootstrap_manifest_*.yaml, schema connector_bootstrap_manifest.schema.json) declares each provider:

version: 2
providers:
  - key: absorb_lms
    name: AbsorbLms
    import_path: github.com/ductor-io/ductor/infrastructure/connector/providers/absorb_lms
    capabilities:
      supports_oauth: true
      supports_refresh: false
      supports_shared_webhook: false
      supports_poll_triggers: false
    provider:
      display_name: Absorb LMS
      release_stage: beta
      categories: [other]
      auth_types: [oauth2, none]
      base_url: https://${connectionConfig.portalRoute}
      oauth2:
        auth_url: https://${connectionConfig.portalRoute}/oauth/authorize
        token_url: https://${connectionConfig.portalRoute}/oauth/token
        grant_type: authorization_code

The generator (internal/cmd/connectorbootstrapgen) reads every manifest file and emits chunked cmd/ductor/fx_connector_generated_*.go files. For each provider it emits a client constructor, a register_<key> function that calls providerkit.RegisterFromSpec with the inlined spec, and the fx wiring. Validate a manifest with make connector-manifest-validate.

Manifest for the standard shape, Go for the exceptions

If your provider is a normal HTTP API with a standard auth type, a manifest entry is the least code. If it needs in-process logic, custom hooks, or bespoke action bodies, write a Register function and hand-author the ActionSpecs — the same RegisterFromSpec call sits at the bottom of both.

Where to go next