Skip to Content

Plugin

This page covers building custom plugin providers. If you only need to configure a published plugin in a Gestalt deployment, use Providers > Plugin.

Plugins are one provider kind. Their manifests use a top-level spec: block, and deployments reference them under plugins. A plugin can define operations as executable code (handled by the provider process), as passthrough surfaces (REST, OpenAPI, GraphQL, or MCP endpoints forwarded by the host), or both.

The examples on this page use a Slack plugin as the running example, since it demonstrates most plugin features: OAuth connections, REST passthrough surfaces, executable operations, and MCP.

Manifest

You declare static metadata, connections, and passthrough surfaces in a manifest file. Gestalt accepts manifest.yaml, manifest.yml, or manifest.json.

kind: plugin source: github.com/your-org/plugins/slack version: 0.0.1 displayName: Slack description: Send messages, search conversations, and manage channels. spec: mcp: true connections: default: auth: type: oauth2 authorizationUrl: https://slack.com/oauth/v2/authorize tokenUrl: https://slack.com/api/oauth.v2.access accessTokenPath: authed_user.access_token scopeParam: user_scope scopeSeparator: ',' scopes: - channels:read - chat:write - search:read - users:read

The manifest is the source of truth for display name, description, the connection model, and any passthrough API surfaces (REST, OpenAPI, GraphQL, or MCP). Setting spec.mcp: true exposes the plugin’s operations through Gestalt’s MCP server. See Provider Manifests for the full field reference.

source may include nested segments after the repository, such as github.com/your-org/plugins/catalog/slack. Gestalt keeps the final segment as the plugin name and uses the full path for release tag resolution.

Creating a project

You need Go 1.26+ and a running gestaltd instance for testing.

mkdir slack && cd slack go mod init github.com/your-org/plugins/slack go get github.com/valon-technologies/gestalt/sdk/go

Recommended layout:

slack/ go.mod manifest.yaml provider.go

Defining operations

Operations are the callable units of a plugin. Each operation has an ID, an HTTP method, input and output types, and a handler function. You do not write an entrypoint. Gestalt synthesizes the executable wrapper automatically for local source execution and release.

The following example defines a conversations.getMessage operation that fetches a single Slack message by channel and timestamp. The handler uses the caller’s OAuth token from the request context to call the Slack API.

package provider import ( "context" "net/http" gestalt "github.com/valon-technologies/gestalt/sdk/go" ) type Provider struct{} type GetMessageInput struct { Channel string `json:"channel" doc:"Channel ID" required:"true"` TS string `json:"ts" doc:"Message timestamp" required:"true"` } type GetMessageOutput struct { Message map[string]any `json:"message"` } var ( getMessageOp = gestalt.Operation[GetMessageInput, GetMessageOutput]{ ID: "conversations.getMessage", Method: http.MethodGet, Description: "Get a single message from a Slack channel", } Router = gestalt.MustRouter( gestalt.Register(getMessageOp, (*Provider).getMessage), ) ) func New() *Provider { return &Provider{} } func (p *Provider) Configure(_ context.Context, _ string, _ map[string]any) error { return nil } func (p *Provider) getMessage( ctx context.Context, input GetMessageInput, req gestalt.Request, ) (gestalt.Response[GetMessageOutput], error) { // Call Slack API using req.Token as Bearer token. // Fetch conversations.history filtered to the single message at input.TS. return gestalt.OK(GetMessageOutput{Message: map[string]any{"ts": input.TS}}), nil }

By default, input decode failures return a structured 400 response, and unhandled errors return a structured 500 response. Return a non-2xx response explicitly when the operation wants to signal a deliberate application-level error.

Input parameter conventions

The router derives catalog parameters from the input type. Fields are required by default unless marked optional. Nested types are emitted as object parameters and collections as array.

type ListMessagesInput struct { Channel string `json:"channel" doc:"Channel ID" required:"true"` Limit int `json:"limit,omitempty" doc:"Maximum messages" default:"100"` Oldest string `json:"oldest,omitempty" doc:"Unix timestamp bound"` }

json:"name" sets the parameter name. json:",omitempty" makes the parameter optional. doc:"..." sets the description, required:"true|false" overrides the default inference, and default:"..." sets a scalar default.

Hosted HTTP endpoints

Hosted HTTP endpoints let an external product call a plugin through a stable HTTP route, while the plugin still handles the request as a normal operation. They are useful for webhooks, Slack slash commands, Slack event callbacks, and other inbound requests whose path, signature verification, body shape, or acknowledgment response needs to match the calling product.

The endpoint declaration belongs in the plugin manifest under spec.http and spec.securitySchemes. Deployment config selects the plugin and may override fields, but most deployments do not need a separate route declaration. If the configured plugin key is slack and the binding path is /commands/support, Gestalt mounts the route at /api/v1/slack/commands/support on server.baseUrl.

Each binding names a security scheme. Use type: none only for routes that are intentionally unsigned; signed production webhooks should use hmac, apiKey, or http verification. The binding then targets an operation ID. Gestalt verifies the request, decodes query/body parameters, optionally resolves a more specific subject, and invokes the target operation.

kind: plugin source: github.com/your-org/plugins/slack version: 0.0.1 spec: securitySchemes: slack_signed: type: hmac secret: env: SLACK_SIGNING_SECRET signatureHeader: X-Slack-Signature signaturePrefix: v0= payloadTemplate: "v0:{header:X-Slack-Request-Timestamp}:{raw_body}" timestampHeader: X-Slack-Request-Timestamp maxAgeSeconds: 300 http: support_command: path: /commands/support method: POST credentialMode: none security: slack_signed target: handle_support_command requestBody: required: true content: application/x-www-form-urlencoded: {} ack: status: 200 body: status: accepted

Request query parameters are included in the operation input. JSON object bodies merge into the input by field name, form-encoded bodies become string parameters, and unsupported body types are passed as rawBody. If ack is configured, Gestalt sends that response immediately and runs the operation asynchronously. Without ack, the caller receives the operation response.

Declarative plugins

Not every plugin needs executable code. If the upstream API is already reachable over HTTP, you can expose it as a set of REST operations declared entirely in the manifest. Gestalt forwards each invocation to the upstream, injecting the caller’s credentials automatically. No provider process runs.

The following manifest exposes three Slack API endpoints as Gestalt operations, with no code at all:

kind: plugin source: github.com/your-org/plugins/slack-readonly version: 0.0.1 displayName: Slack (read-only) description: Read Slack channels and messages via the REST API. spec: mcp: true surfaces: rest: baseUrl: https://slack.com operations: - name: conversations.list description: List conversations and channels method: GET path: /api/conversations.list parameters: - name: limit type: int in: query - name: types type: string in: query - name: conversations.history description: Read message history for a channel method: GET path: /api/conversations.history parameters: - name: channel type: string in: query required: true - name: limit type: int in: query - name: oldest type: string in: query - name: users.list description: List workspace users method: GET path: /api/users.list parameters: - name: limit type: int in: query connections: default: auth: type: oauth2 authorizationUrl: https://slack.com/oauth/v2/authorize tokenUrl: https://slack.com/api/oauth.v2.access accessTokenPath: authed_user.access_token scopeParam: user_scope scopeSeparator: ',' scopes: - channels:read - users:read

You configure a declarative plugin the same way as any other. Point the config at the manifest, and the declared operations appear in the catalog:

plugins: slack-readonly: source: ./slack-readonly/manifest.yaml
gestalt plugin invoke slack-readonly conversations.list -p limit=5

Gestalt handles OAuth token injection, request formatting, and response passthrough. The spec.surfaces.rest.baseUrl is the root for all declared paths, and each operation’s parameters define how input fields map to query parameters or request body fields.

You can also declare OpenAPI, GraphQL, and MCP surfaces using spec.surfaces.openapi, spec.surfaces.graphql, and spec.surfaces.mcp. See Provider Manifests for the full surface schema.

Operation ID conventions

Operation IDs should use period-separated hierarchical names. The Slack plugin uses conversations.list, conversations.history, chat.postMessage, and users.info.

This convention enables prefix matching in the CLI. When you run gestalt plugin invoke slack conversations, the CLI joins the space-separated segments with periods and shows all operations whose ID starts with conversations.. This makes large catalogs navigable without memorizing every operation name.

Dynamic catalogs

By default, the operation catalog is static and materialized at startup. If your plugin needs to expose different operations depending on the caller’s credentials or runtime state, implement a session catalog.

A Slack plugin could use this to check which Slack workspace the caller is connected to and adjust the available operations accordingly.

Implement the SessionCatalogProvider interface on your provider:

func (p *Provider) CatalogForRequest(ctx context.Context, token string) (*gestalt.Catalog, error) { return &gestalt.Catalog{ Name: "slack-session", DisplayName: "Slack", Operations: []gestalt.CatalogOperation{ { ID: "conversations.getMessage", Method: http.MethodGet, Annotations: gestalt.OperationAnnotations{ ReadOnlyHint: true, }, }, }, }, nil }

Testing locally

Use the provider-local commands when you are iterating on a source plugin. gestaltd provider dev and gestaltd provider validate synthesize a local Gestalt config around the target plugin instead of requiring a hand-written temp config. If the plugin manifest declares spec.ui.path, Gestalt mounts that owned UI automatically. For the common plugin/ + sibling ui/ layout, the local commands also detect ../ui/manifest.yaml and wire it in automatically during local development.

Validate the plugin in isolation:

gestaltd provider validate --path ./slack

Run the plugin locally with browser-facing UI available:

gestaltd provider dev --path ./slack

Validate or serve the UI bundle directly when you are working on the frontend itself:

gestaltd provider validate --path ./ui gestaltd provider dev --path ./ui

When the plugin needs supporting providers, extra plugins, or explicit mounted UIs, layer real config overlays on top of the synthesized baseline:

gestaltd provider validate --path ./slack --config ./dev/support.yaml gestaltd provider dev --path ./slack --config ./dev/support.yaml

Repeated --config flags merge left-to-right, and null deletes inherited entries. That makes it possible to boot some supporting providers while suppressing others for a local session.

Attaching local code to a remote server

Use remote provider dev when you want a remote Gestalt instance to handle auth, authorization, connections, secrets, and host services, but route a selected plugin implementation to your local machine. For the normal single-plugin case, a path-only attach is enough:

export GESTALT_URL=https://gestalt.example.com gestaltd provider dev --remote "$GESTALT_URL" --path ./slack

The remote URL is environment-agnostic; it can point at any gestaltd instance that exposes provider-dev attach targets and has opted in with server.providerDev.remoteAttach: true and server.providerDev.attachmentState: processLocal. The target plugin must also opt into private remote attach with providerDev.attach.allowedRoles. The CLI opens a browser approval page for the interactive happy path; approve it with your normal browser session and enter the verification code shown in your terminal. API-token callers must use a token with the provider_dev.attach action for that plugin. The local command replaces only the source plugin attached by your authenticated session. Providers that are not attached locally continue to run through the remote server and its existing configuration.

server: providerDev: remoteAttach: true attachmentState: processLocal authorization: policies: provider_devs: default: deny members: - subjectID: user:usr_123 role: developer plugins: slack: authorizationPolicy: provider_devs providerDev: attach: allowedRoles: - developer

For non-interactive attach, create or supply an API token with an action permission. Provider scopes and operation permissions are intentionally not enough, and this action does not grant normal invocation access:

{ "name": "slack local dev", "permissions": [ { "plugin": "slack", "actions": ["provider_dev.attach"] } ] }

Remote attach v1 is process-local, and the server requires server.providerDev.attachmentState: processLocal as an explicit operator acknowledgement. On a load-balanced remote, enable it only when the deployment is single-replica or sticky-routes attach creation, CLI poll/complete traffic, provider invocations, and provider-owned UI requests for the same authenticated owner to the same gestaltd process. Remote provider dev uses --remote-token first, then GESTALT_API_KEY, then a stored CLI token when the stored credential’s server base URL matches --remote. An API token used for direct attach must include permissions[].actions: ["provider_dev.attach"] for every attached plugin. A stored CLI token without that action is not enough for direct attach, so the CLI falls back to browser approval when available. A stored credential for a different server URL, including a different path prefix on the same host, is not sent; the browser approval flow is used instead. Use --remote-token or GESTALT_API_KEY for non-interactive automation.

Remote attach creates an owner-scoped attachment. The server returns an attachId and a one-time dispatcher secret; the CLI uses both for local poll/complete traffic. Other users keep seeing the deployed provider and UI. Owner cleanup can detach a stuck attachment by attachId:

gestaltd provider attach list --remote "$GESTALT_URL" gestaltd provider attach show --remote "$GESTALT_URL" att_123 gestaltd provider attach detach --remote "$GESTALT_URL" att_123

These diagnostic commands use --remote-token, GESTALT_API_KEY, or a matching stored Gestalt CLI credential for the same remote URL. They do not require provider_dev.attach because they only list, inspect, or detach attachments owned by the authenticated principal. Diagnostic list/get responses never expose dispatcher secrets, host-service env, plugin config, access tokens, or browser binding URLs.

For --remote --path without --config, Gestalt matches the local manifest source to a configured plugin on the remote server. The remote plugin’s configured auth, authorization, connections, plugin config, mounted UI settings, secret references, and host services are preserved; only the implementation is routed to your local source tree. If more than one remote plugin uses the same manifest source, or the manifest has no source, pass --name:

gestaltd provider dev --remote "$GESTALT_URL" --path ./slack --name slack

Use layered config files only when you want explicit local config overrides or when you want to attach multiple local plugins in one session:

gestaltd provider dev \ --remote "$GESTALT_URL" \ --config ./base.yaml \ --config ./prod.yaml \ --path ./slack \ --name slack

You can also attach every source-backed plugin from layered config overlays:

gestaltd provider dev \ --remote "$GESTALT_URL" \ --config ./base.yaml \ --config ./prod.yaml \ --config ./dev/local-plugins.yaml

Config-driven remote attach uses the same merge behavior as local development, so overlays can choose which plugins are local by adding source-backed plugin entries, choose which remote/supporting providers are disabled by setting inherited config entries to null, and send explicit local plugin config to the remote session. Unlike local provider dev and provider validate, remote provider dev allows missing local environment substitutions while loading config. That keeps unrelated deployment-only env vars from blocking attach; if the attached plugin itself needs a literal env-substituted value, set that env var locally or pass a small local overlay.

Remote provider dev tunnels plugin RPC, catalog, post-connect, host-service calls, and plugin-owned UI assets for mounted UIs that already exist on the remote server. For the plugin/ + sibling ui/ layout, the sibling UI is bundled for the local provider session automatically. Raw GraphQL surfaces are not tunneled in v1.

For TypeScript plugins, provider dev runs bun install automatically when the plugin has a package.json but no node_modules. Source UI release builds use the same dependency bootstrap for Bun-managed build workdirs before release.build.command when dependencies are missing. Keep @valon-technologies/gestalt current enough for the host-service features your plugin uses; for example, remote IndexedDB host-service calls require an SDK version with tls:// host-service transport support.

Hosted HTTP bindings stay layered on top of operations. A plugin defines an operation like handle_command in code, then attaches /command by declaring a manifest spec.http binding that targets that operation. Local source execution and gestaltd provider release preserve those manifest spec.securitySchemes / spec.http entries in the effective packaged manifest.

Once the local server is running, invoke an operation:

gestalt plugin invoke slack conversations.getMessage -p channel=C01ABC23DEF -p ts=1712161829.000300

If your plugin makes outbound HTTP requests and you see 403 errors, check allowedHosts. In deployments with executable-plugin sandboxing enabled, requests to hosts not in the allowlist are rejected. That sandboxing story is still runtime-dependent today, so unsupported environments may fail differently. Add every upstream domain the provider contacts to allowedHosts in the config or manifest.

Hybrid providers

A manifest can define passthrough surfaces alongside executable operations. The passthrough surfaces are forwarded by the host directly to the upstream API, while the executable operations are handled by the provider process.

The first-party Slack plugin is a hybrid provider. It declares 14 REST operations in its manifest (such as chat.postMessage, conversations.list, and users.info) that Gestalt forwards directly to the Slack API. On top of those, it defines 3 executable operations (such as conversations.getMessage) that add custom logic like URL parsing and multi-API orchestration. Both sets of operations appear in the same catalog.

Do not define the same operation ID in both a passthrough surface and an executable operation. If two surfaces expose the same ID, the provider fails validation at startup. Use allowedOperations to filter or alias collisions when needed.