One Contract, Many Services

In Corsair, a plugin is the unit where a provider’s behavior enters a shared application runtime. The important question is what that unit contains beyond a URL and an authentication header, and how the core binds it into a predictable client without pretending that Slack and GitHub work alike. Following that boundary gives a builder a practical way to judge type safety, reuse, and the limits of the abstraction.

A plugin is a provider contract, not a thin wrapper

A thin wrapper might expose a few functions that send HTTP requests. Corsair’s plugin model is broader because a useful integration has several kinds of behavior. It needs callable operations, a way to describe their inputs and outputs, rules for obtaining the right credential, representations of resources worth keeping locally, and handlers for events that arrive later. It may also need hooks, provider-specific error responses, and metadata that tells an agent whether an operation reads, writes, or destroys something.

The public shape makes the common part easy to recognize. A configured plugin appears beneath its provider name, with operations such as corsair.slack.api.messages.post(...) or corsair.github.api.issues.create(...). The path follows the same pattern—plugin, API namespace, resource, action—even though the arguments and results belong to Slack or GitHub. A Slack message has Slack identifiers and rules; a GitHub issue has GitHub identifiers and rules. The uniform path is an interface convention, not a claim that the resources are interchangeable.

A plugin can expose a parallel local database namespace. If the provider defines a message entity, the application can reach its synchronized representation through something like corsair.slack.db.messages. Webhooks sit beside the API and database surfaces, because the provider may change a resource without the application initiating a request. Account keys sit beside them as the place where the plugin obtains its tenant-level credential. These surfaces are related, but they do different jobs: an API operation acts, a database client reads stored state, a webhook handles an external event, and a key manager resolves secrets.

The contract also carries the information that lets the core and other consumers work without provider-specific plumbing. Endpoint input and output schemas describe the shape of a call. Endpoint metadata supplies a human-readable description and a risk level. Webhook schemas describe incoming payloads and responses. Authentication configuration tells the core which fields belong to an integration and which belong to an account. A key builder translates that configuration into the credential a particular provider request needs.

classDiagram class CorsairPlugin class ApiEndpoints class EntitySchemas class WebhookHandlers class AuthAndKeys class OperationMetadata class CorsairBinder class BoundNamespace CorsairPlugin --> ApiEndpoints : supplies CorsairPlugin --> EntitySchemas : supplies CorsairPlugin --> WebhookHandlers : supplies CorsairPlugin --> AuthAndKeys : defines CorsairPlugin --> OperationMetadata : describes CorsairBinder --> CorsairPlugin : binds CorsairBinder --> BoundNamespace : produces BoundNamespace --> ApiEndpoints : exposes BoundNamespace --> EntitySchemas : exposes BoundNamespace --> WebhookHandlers : exposes

That contract is visible in the repository’s Slack and GitHub plugins. Slack declares nested groups for channels, messages, users, files, reactions, and administrative operations. It pairs those groups with Zod schemas, event handlers, risk descriptions, OAuth settings, a webhook matcher, tenant matching, and a key builder. GitHub uses the same categories but supplies its own issues, pull requests, repositories, releases, workflow, search, and event behavior. The contrast is instructive: the core can recognize both as Corsair plugins, while the plugin still has to preserve the vocabulary and constraints that make each provider useful.

TypeScript is what makes the contract more than a naming convention. The endpoint tree is transformed so that an internal function receiving context and arguments becomes a public function receiving the arguments appropriate to that provider. If a Slack channel creation requires a name, the editor can surface that requirement; if a GitHub operation returns a particular issue shape, the result can be checked against that shape. Database entity clients are likewise typed from the plugin’s schemas. An invalid property is not merely a runtime surprise when the schema and generated types expose enough information to reject it earlier.

The same type information supports configuration. Corsair derives valid dot-notation paths from an endpoint tree, so risk metadata and permission overrides can be attached to real operations rather than arbitrary strings. This matters when the endpoint is later exposed to an agent: its description, input shape, and risk classification can travel with the operation. A developer’s editor, a documentation generator, and an agent can therefore consume related descriptions rather than each maintaining a separate inventory.

This arrangement creates leverage, but it does not erase provider differences. The core can standardize how an operation receives context or how a database client is reached. It cannot decide whether a provider’s identifier is a channel ID, a repository name, or an installation ID. It cannot infer which OAuth scopes are sufficient, how a provider expresses rate limits, or which event payload represents a meaningful update. Those decisions belong in the plugin because they are facts about the external service.

What the core binder adds to every call

The plugin definition is not the object an application invokes directly. Corsair’s core binds it into a client with the surrounding context already attached. That binding step is where the common lifecycle becomes real. It supplies the database adapter, a tenant-aware account resolver, the plugin’s entity clients, the configured options, the key manager, the bound endpoint tree, and—when relevant—the Hub configuration and tenant identifier.

When an application calls an endpoint, the core can therefore perform a sequence of checks before the provider-specific function runs. A read-only runtime scope can reject a non-read operation. If the plugin has permission metadata, Corsair resolves the endpoint’s risk level and policy. A read may be allowed, a write may be allowed or require approval, and a destructive action may be denied or held for a reviewer. If approval is required, the arguments and tenant are recorded before the provider call is attempted. That policy is attached to the bound operation rather than left to each application feature to remember.

Next comes credential construction. The plugin’s key builder receives the bound context and knows whether the request is an endpoint call or a webhook. For an API-key plugin it can retrieve the account’s stored key. For OAuth it can retrieve a current access token or enter the refresh path. For a webhook it can select the provider’s signing secret. This is why the same public method can be called without putting a token in every feature function: credential lookup is part of the provider contract and the core supplies the scoped key manager.

The provider implementation then runs with context that can include database services and other bound endpoints. That allows an endpoint to normalize a provider response into an entity schema or use a related operation without making the application rebuild the integration’s internal wiring. The result can be passed through an after hook, while a before hook can validate or transform arguments first. The order is meaningful: a before hook can stop an unsafe or irrelevant call before the provider sees it; an after hook can trigger an application side effect only after the operation succeeds.

This binding is also what keeps the public namespace honest. The application sees a function that accepts the operation’s arguments, but the function still carries the context needed to make the call belong to the configured Corsair instance. The account resolver knows which provider account corresponds to the active tenant. The entity client knows which provider entity type and schema it is handling. The key builder knows whether it is constructing an API credential or a webhook-verification key. These are not incidental conveniences; they are the links between a method name and the state that gives the method meaning.

The links are created once and reused. Corsair builds a client for the configured plugins, creates the account-level key manager when database and KEK configuration are available, and then binds endpoint and webhook trees against that context. A call does not need to assemble a new client or rediscover the provider’s storage model. At the same time, a plugin can expose no database entities at all if live API access is all it needs, or it can provide several entity types when local reads are central to the integration. The contract permits those differences without changing the outer shape.

There is a useful distinction between a schema and a promise of correctness. A Zod input schema can tell the application that a field is a string or that a value is optional. An output schema can make the returned shape inspectable. Neither schema, by itself, proves that the provider will accept every combination of fields or that an event has been normalized correctly. The provider adapter must connect the declared shape to real provider behavior, and tests must exercise the boundary where that behavior can diverge from the type model.

Errors travel through the same boundary. Corsair combines plugin-specific and root-level error handlers, giving a provider’s rate-limit behavior priority over a generic fallback. A handler can select a retry count and a strategy such as exponential backoff with jitter. The core retries the bound operation according to that result rather than forcing every feature author to reproduce the provider’s error interpretation. If the account has no usable credential, the failure is different: an authentication-missing or reconnect-required error can lead the application into a connection flow instead of being mistaken for a transient API outage.

This is the core trade-off in the plugin design. Centralizing retries, hooks, credential lookup, entity-client wiring, and policy reduces duplicated lifecycle code. At the same time, it adds a contract that plugin authors must implement correctly. A plugin can compile while still choosing the wrong endpoint parameters, omitting a provider’s pagination rule, normalizing an event incorrectly, or assigning an unsafe risk level. The common machinery makes mistakes more consistently handled; it does not make provider knowledge unnecessary.

The schemas and metadata also have two audiences. For application code, they make calls and returned entities easier to inspect. For agents, they make an operation discoverable: a model can learn what an endpoint is called, what arguments it accepts, and what risk category the plugin assigns to it. That information is useful only if the plugin’s descriptions and schemas are faithful. A convenient common syntax cannot compensate for a misleading description or an incomplete response model.

The abstraction stops at the point where external meaning begins. Plugin authors remain responsible for provider authentication details, endpoint coverage, response and entity shapes, webhook signatures, tenant-link identifiers, and tests against real or representative provider behavior. The custom-plugin guide reflects this division: the generator can scaffold the contract, but the author must replace example operations, define the relevant entities and events, implement the actual client calls, and typecheck and test the result.

That responsibility is not limited to filling in function bodies. A provider may use one credential for live calls and another secret for webhook verification. It may identify an account by a workspace, installation, or organization value that must be captured during connection. It may return a collection whose pagination rules differ from another provider’s collection, or send an update event whose payload is too small to rebuild the entity without a follow-up request. The plugin is where those facts must become explicit. If they remain implicit in application code, the supposedly shared boundary fractures at exactly the places where production behavior is hardest to debug.

The generator is valuable because it makes the required categories visible early. It creates a conventional place for the client, endpoints, schemas, webhooks, database entities, configuration, and tests. That reduces repetitive setup, but it also creates a review checklist: does the endpoint schema match the provider docs, does the entity preserve the identifiers used by future webhooks, does signature verification cover the raw request, and does the risk metadata describe the consequence of the operation? A scaffold answers “where should this go?”; it does not answer “is this mapping correct?”

That limit is a strength when it is understood clearly. Corsair is not trying to turn every service into one giant lowest-common-denominator API. It gives the application a stable outer boundary—one way to bind calls, keys, state, events, errors, and policy—while letting the inner provider contract remain specific. A builder can then add Slack and GitHub to one runtime without learning a new lifecycle architecture for each, while still reading the provider’s documentation when the operation’s meaning matters.

Once a bound operation returns, the next question is no longer how the interface is shaped but where its result belongs. The live provider call, the local entity projection, and the application’s own business tables form a second boundary. Understanding that transition is what turns a consistent plugin interface into usable application state.

References