Build a custom connector
Implement complex auth, response mapping, or multi-step behavior behind the same interface.
When to use code
Use a custom connector when the remote system needs OAuth refresh, signing, multi-step requests, conditional mapping, non-JSON payloads, or response normalization that a declarative endpoint cannot express.
Keep the world-facing contract the same. Complexity belongs behind a connector factory, not in the simulation or API routes.
Implement the interface
import type { Connector } from "@/practice/connector";
export function createNorthStarConnector(fetcher = fetch): Connector {
return {
id: "north-star",
label: "North Star",
ping: async () => ({ ok: true }),
ensureActor: ({ profile }) => prepareActorCall(fetcher, profile),
createBooking: ({ appointment, remoteActorId }) =>
prepareBookingCall(fetcher, appointment, remoteActorId),
cancelBooking: ({ remoteBookingId, reason }) =>
prepareCancellationCall(fetcher, remoteBookingId, reason),
sendMessage: ({ remoteActorId, channel, body }) =>
prepareMessageCall(fetcher, remoteActorId, channel, body),
};
}Passing dependencies into the factory keeps the connector testable and avoids hidden global clients.
Prepared calls
Action methods return a ConnectorCall with three parts:
| Field | Meaning |
|---|---|
request | The method, path, and body recorded before execution |
execute | The async function that performs the remote action |
readRemoteId | Optional response reader for actor or booking mappings |
return {
request: { method: "POST", path: "/contacts", body },
execute: () => client.createContact(body),
readRemoteId: (response) => response.contact.id,
};Register it
Add the factory behind the connector lookup in src/server/connectors/registry.ts, and include a matching descriptor so the panel can discover it. Keep file-driven connectors as the default path and isolate custom registrations by id.