> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parmanasystems.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Connector Development Guide

> How to implement a Connector against @parmana/connector-sdk, every example uses MockConnector or HttpConnector, no enterprise-specific example.

<Info>**\[AVAILABLE] as a library**, `packages/connector-sdk`, **\[AVAILABLE] wired into the default server for Razorpay and HubSpot specifically**, a code change for anything beyond those two today, see the warning below.</Info>

## Connector responsibilities, and what a Connector must never do

A Connector validates a request, executes using an already-resolved credential, and
returns a response with a fixed, predictable shape. A Connector never evaluates policy, authorizes
execution, interprets AI output, makes a business decision, or resolves a credential
itself, credential resolution happens exclusively inside the Execution Gateway, before a
Connector is ever called.

```typescript theme={null}
import {
  connectorCapabilities,
  type Connector,
  type ConnectorExecutionContext,
  type ConnectorRequest,
  type ConnectorResponse,
} from "@parmana/connector-sdk";

class ExampleConnector implements Connector {
  readonly connectorId = "example";
  readonly capabilities = connectorCapabilities(["crm:read"]);

  async execute(request: ConnectorRequest, context: ConnectorExecutionContext): Promise<ConnectorResponse> {
    if (!this.capabilities.includes(request.capability)) {
      throw new Error(`does not declare capability "${request.capability}"`);
    }
    // context.credential is an opaque CredentialHandle, read it, never log it.
    return { success: true, metadata: { recordId: "example-1" } };
  }
}
```

## Capabilities are namespaced verbs

`ConnectorCapability` is a plain string, validated eagerly by `connectorCapabilities()`
against `namespace:verb` (e.g. `http:get`, `crm:read`, `payments:refund`), a malformed
capability throws at connector construction, not at execution time. By convention,
`ExecutableContent.action` *is* the capability string; this is what
`execution-control`'s `DefaultConnectorPolicy` already checks unchanged.

## Registering a connector

```typescript theme={null}
import { ConnectorSdkRegistry, StaticCredentialProvider, healthyNow } from "@parmana/connector-sdk";

const registry = new ConnectorSdkRegistry();

registry.register({
  connector: new ExampleConnector(),
  metadata: {
    connectorId: "example",
    displayName: "Example CRM",
    version: { major: 1, minor: 0, patch: 0 },
    health: healthyNow(),
  },
  connectorIdentity: { connectorId: "example", publicIdentity: "spiffe://parmana/connectors/example", authenticationMetadata: {} },
  credentialProvider: new StaticCredentialProvider({ example: { token: "..." } }),
  policy, // a ConnectorPolicy, e.g. CapabilityConnectorPolicy(new DefaultConnectorPolicy(...))
  gatewayAuthentication,
  crypto, // @parmana/crypto CryptoProvider, reused for evidence hashing, not reimplemented
});
```

`registry` implements `execution-control`'s `ConnectorRegistry` interface (`get()`), so it
plugs directly into `ExecutionControlService` exactly like `InMemoryConnectorRegistry`.

## Testing your connector hermetically

Use `MockConnector` to test anything upstream of your connector (policy wiring, routing)
without a real target system:

```typescript theme={null}
import { MockConnector, connectorCapabilities } from "@parmana/connector-sdk";

const mock = new MockConnector({
  connectorId: "example",
  capabilities: connectorCapabilities(["crm:read"]),
  script: { respond: () => ({ success: true, metadata: { recordId: "example-1" } }) },
});

const result = await mock.execute(
  { capability: "crm:read", businessTransactionId: "t1", action: "crm:read", target: "crm://record/1", parameters: {} },
  { credential: undefined as never },
);
```

Real output, checked directly against this exact code:

```
mock result: { success: true, metadata: { recordId: 'example-1' } }
mock invocations: 1
```

To test failure handling, use `script: { failWith: new Error("upstream unavailable") }`.
`mock.invocations` records every `ConnectorRequest` the connector received, so tests can
assert exactly what was executed.

## Version and health checks fail closed

`SdkConnectorExecutor` rejects execution, before invoking your connector, if:

* an `expectedVersion` was configured at registration and the connector's own
  `metadata.version` doesn't match (guards a rolling deployment that swapped connector
  builds underneath a pinned expectation), or
* `metadata.health.status === "unavailable"`.

## What every connector's evidence looks like

Every execution produces a `ConnectorEvidence` object, connector ID, version, capability,
a sanitized endpoint (credentials and query parameters stripped), the credential provider's
ID, redacted request/response summaries, timestamps, and a hash computed by the existing
`TrustRecordHasher`, attached at `ExecutionResult.metadata.connector`. See
[Execution Trust Record](/concepts/execution-trust-records) for how this reaches the Trust Record
without any change to its schema or hashing pipeline.

## What reaching the default server actually requires

<Warning>
  This is the part that's easy to assume is configuration and isn't. **Update (Phase 2A),
  historical record:** the default *production* server used to register a connector
  (`vendor-payment`) unconditionally, including in production; that was a trust gap (a
  request would get a genuinely signed success Trust Record with no real vendor ever
  contacted) and was closed by gating it to `NODE_ENV=test` only. **Final update
  (2026-08-09):** `vendor-payment` was subsequently removed from the repository entirely —
  `createVendorPaymentConnector.ts` and its dedicated credential provider no longer exist in
  any form — rather than kept as a gated reference example. See `docs/VERIFICATION-GAPS.md`
  G-27 in the source repository. The real, current registration pattern, copied verbatim from
  source:

  ```typescript theme={null}
  // packages/api/src/bootstrap/createConnectorRegistry.ts (abridged, real source)
  const razorpayCredentialProvider = createRazorpayCredentialProvider();
  if (razorpayCredentialProvider !== undefined) {
    registrations.push({
      connector: createRazorpayConnector(),
      metadata: RazorpayMetadata,
      connectorIdentity: { connectorId: "razorpay", publicIdentity: "spiffe://parmana/connectors/razorpay", authenticationMetadata: {} },
      credentialProvider: razorpayCredentialProvider,
      policy: new DefaultConnectorPolicy(authenticator, sessions),
      gatewayAuthentication,
      crypto,
      audit,
    });
  }
  ```

  Adding a connector to the running server means editing two files:
  `createConnectorRegistry.ts` (register it, following the shape above — return `undefined`
  in production until a real implementation exists, exactly like `createRazorpayCredentialProvider.ts`/
  `createHubSpotCredentialProvider.ts` already do) and `createConnectorAuthenticator.ts`
  (trust its identity, add an entry to its hardcoded list). **Not** `createConnectorRoute.ts`
  — despite its name, that file's `route()` function is dead code in the current production
  wiring (`createExecutionGateway.ts` always supplies `executionControl.service`, which makes
  the only branch that calls `route()` unreachable); editing it has no effect on which
  connector actually handles a request. There is no dynamic registration path, no environment
  variable that adds a connector, this is a code change today. Razorpay and HubSpot are the
  two connectors actually reachable in the default production server, see
  [Razorpay](/integrations/razorpay) and [HubSpot](/integrations/hubspot) for those connectors
  specifically. See
  [the Phase 2A production-connectors report](/../architecture/phase2a-production-connectors.md)
  for the vendor-payment before/after this superseded.
</Warning>

## The pattern, proven twice: Razorpay and HubSpot

Both connectors implement everything above, but the two builds themselves are the more
useful lesson: Razorpay learned each point below the hard way, on a running connector,
after the fact. HubSpot applied all of it from its first version instead. Read this before
starting a third connector.

**Scope to one narrow action first.** Don't build a connector for a whole external API
surface. Razorpay started with refund creation only, not payouts, not subscriptions, not
the rest of the payments API. HubSpot started with one property update on one object type,
a Deal's `dealstage`, optionally alongside `amount`, not Contacts, not Companies, not
delete. Widen scope in a later milestone, once the first one is fully proven, see each
connector's own \[FUTURE] section on its page ([Razorpay](/integrations/razorpay),
[HubSpot](/integrations/hubspot)).

**Two structural guards belong in a connector's first version, not a follow-up fix.**

* *Refuse a placeholder test credential against the real endpoint, before any network
  call.* Don't rely on the vendor happening to reject it, that's an accident of their
  behavior, not something this codebase controls. `RazorpayConnector` didn't have this
  check originally, it survived only because Razorpay's real API happened to reject an
  unrecognized key — a defense-in-depth fix was added after the fact.
  `HubSpotConnector` refuses its own placeholder token against
  `https://api.hubapi.com` from its first commit, see [HubSpot](/integrations/hubspot) and
  `packages/connector-hubspot/src/HubSpotConnector.ts`.
* *Read the documented test-credential environment variable name directly, never through a
  bridge variable.* `createRazorpayCredentialProvider.ts`'s test-mode branch originally read
  a word-order-swapped bridge variable instead of the name `.env.example` actually
  documented, fixed only after it drifted out of sync.
  `createHubSpotCredentialProvider.ts` reads `TEST_HUBSPOT_PRIVATE_APP_TOKEN`, the exact
  documented name, with no bridge to get wrong.

**Apply `boundSignals` hardening from the policy's first version, don't wait for an
adversarial-testing session to find the gap.** `razorpay-refund/1.0.0` didn't declare
`boundSignals` until a live demonstration showed a caller could declare small, verified
signals while `intent` executed something else, closed in an adversarial-testing
hardening session. `hubspot-deal-update/1.0.0` declares
`boundSignals` binding `proposedDealStage`/`proposedAmount` to their `intent.parameters`
fields from its first commit, closing the same class of gap before any live demonstration
of it existed. See [Policies and the decision](/concepts/policies-and-the-decision).

**Test order, every time:**

1. **Hermetic first.** The full authorize → verify → execute → confirm chain against a mock
   server, zero network calls. Both connectors' unit suites run this way on every
   `npm test`.
2. **Policy-denial-makes-zero-calls, proven at two layers.** A direct state check against
   the mock server (nothing changed), and a `fetch` spy at the HTTP boundary (literally zero
   calls reached the mock or real endpoint). See [HubSpot](/integrations/hubspot) for what
   that looks like in a captured response.
3. **A gated live suite last**, behind an `ALLOW_LIVE_<CONNECTOR>=1` flag checked alongside a
   real test credential, skipped by default so a routine `npm test` never makes a live call
   by accident.

**Prefer a non-destructive live-test action where the target system allows one.** Read live,
apply a small reversible change, verify independently, revert, in the same run. HubSpot's
live suite nudges a real test deal's `amount` by a small within-threshold delta and reverts
it before finishing, safe to rerun indefinitely. Razorpay's refund is irreversible by
construction, its captured test payment's refundable remainder depletes by a fixed amount
on every live run — a real operational cost of testing against a real endpoint.

**Disclose what actually happened, including when the mechanism catches your own mistake.**
HubSpot's first live amount-change test run failed with a policy denial the test author
didn't expect, because the test's own signals payload omitted `proposedAmount`,
`SignalIntentBinder` correctly rejected the mismatch before the intended amount-threshold
rule ever ran. That's not a connector defect, it's the binding working exactly as designed,
against a test's own mistake this time instead of a caller's, and it's written up that way
in `docs/CLAIMS.md` §3.10 rather than quietly fixed and left undocumented.

## Troubleshoot

* **`does not declare capability "..."` thrown by `MockConnector` or your own connector.**
  `request.capability` didn't match anything in `connectorCapabilities([...])`. Check for a
  typo, capability strings are exact, case-sensitive matches.
* **`SdkConnectorExecutor` rejects with a version mismatch.** An `expectedVersion` was
  configured at registration and your connector's `metadata.version` doesn't match, this
  guards a rolling deployment that swapped connector builds underneath a pinned expectation.
* **Registered your connector but `POST /execute` still says "No connector registered for
  action."** Registration in a `ConnectorSdkRegistry` you constructed yourself doesn't reach
  the running default server, that server builds its own registry in
  `createConnectorRegistry.ts`, see the Warning above.

## Next

<CardGroup cols={2}>
  <Card title="Issue and verify session credentials" icon="key" href="/guides/session-credentials">
    What your connector's `context.credential` actually is, and its lifecycle.
  </Card>

  <Card title="Credential isolation" icon="shield" href="/concepts/credential-isolation">
    The concept this guide's credential handling exercises.
  </Card>
</CardGroup>
