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.
YAML
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:readThe 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
Go
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/goRecommended layout:
slack/
go.mod
manifest.yaml
provider.goDefining 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.
Go
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.
Go
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.
Manifest
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: acceptedRequest 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:readYou 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.yamlgestalt plugin invoke slack-readonly conversations.list -p limit=5Gestalt 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.
Go
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 ./slackRun the plugin locally with browser-facing UI available:
gestaltd provider dev --path ./slackValidate or serve the UI bundle directly when you are working on the frontend itself:
gestaltd provider validate --path ./ui
gestaltd provider dev --path ./uiWhen 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.yamlRepeated --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 ./slackThe 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:
- developerFor 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_123These 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 slackUse 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 slackYou 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.yamlConfig-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.000300If 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.
What to read next
- Releasing: packaging and publishing provider releases
- IndexedDB: persistent storage SDK for plugins
- Provider Manifests: full manifest field reference