Membrane Driver Guide
1. Understanding the Driver’s Role
A driver serves as a bridge between Membrane and external APIs. Drivers expose API functionality as nodes in your Membrane graph, making it easy for other programs to interact with external services.
We have drivers for popular APIs like GitHub and Slack. Check out the full list here. Drivers are open source and anyone can create and publish one. Once you get the hang of it, you won’t have to worry much about the quirks of individual APIs since all drivers follow the same structure.
Drivers are regular Membrane programs, so if you’ve written some code on Membrane you’re already halfway there!
For this guide, we’ll walk through the
We’ll demonstrate how to:
- Create an interface for the API that uses Membrane state to store the API key
- Structure your code using common Membrane patterns (Collections, Resources, grefs)
- Follow consistent driver design principles and best practices
2. Core Components
File Organization
A typical Membrane driver should have this structure:
resend/├── index.ts # Main driver code and exports├── helpers.ts # API helper and utilities├── tests.ts # Test cases├── memconfig.json # Driver schema└── README.md # Documentation
Schema
Before we dive into the code, let’s take a look at the driver schema.
The schema is defined in memconfig.json
but won’t generally edit it by hand
(but you can!). Instead you use the Schema Editor on the right sidebar.
The schema for a programs defines the “shape” of its graph.
When writing drivers, the schema should match the API as closely as possible. The list you see above includes:
Configuration
Every driver needs basic configuration and status checks. We add those in the
helpers.ts
file. Here’s how we implement this for Resend:
// helpers.tsimport { state } from "membrane";
// We use Membrane's state to persist the API key between program runs.export function status() {// The [text](:action) syntax makes the action invocable in the Membrane Navigator// The [text](url) markdown syntax creates clickable links in the Navigator return state.API_KEY ? "Configured" : "Please [configure](:configure) your [API key](https://resend.com/api-keys)";}
export async function configure({ apiKey }: { apiKey: string }) { if (!apiKey) { throw new Error("Please provide a valid API key"); } state.API_KEY = apiKey;}
helper.ts
As a convention, utility functions live in helpers.ts
. For this driver, we
have an api
function that calls fetch
with the appropriate headers, query
and authentication, as well as parsing the response as JSON when appropriate.
Most drivers will need a similar function to make the actual HTTP requests:
// helpers.tsconst BASE_URL = "https://api.resend.com";
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
export async function api( method: Method, path: string, query?: Record<string, any>, body?: any) { const url = new URL(`${BASE_URL}/${path}`);
// Add query parameters if they exist if (query) { Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.append(key, String(value)); } }); }
const headers = { Authorization: `Bearer ${state.API_KEY}`, "Content-Type": "application/json", };
const response = await fetch(url.toString(), { method, headers, body, });
if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); }
// Note: Response handling varies by API. Some APIs: // - Always return JSON // - Mix JSON and non-JSON responses // - Return different content types for errors vs success // Adjust this logic based on your API's behavior const contentType = response.headers.get("content-type"); if (contentType && contentType.includes("application/json")) { return response.json(); } else { return response.text(); }}
Root Object
In all programs (drivers included) the Root
object serves as the entry point
to all of its functionality. Every program has a Root
type in its schema which
defines the top-level structure of the program’s graph.
Resend’s API exposes two types of resources through their /emails
and
/domains
endpoints.
To expose that functionality via the Membrane graph, the driver’s Root
type
provide corresponding field resolvers:
// index.tsimport { root } from "membrane"import { status, configure, api } from "./helpers"import { emailTests, domainTests } from "./tests"
export const Root = { // Status check for configuration status,
// Configuration action configure,
// Collection fields emails: () => ({}), // EmailCollection domains: () => ({}), // DomainCollection
// Test fields tests: () => ({}),}
Note that the resolvers don’t do much, they simply return an empty object. This
tells the GraphQL executor to continue the query by invoking the resolvers in
EmailCollection
and DomainCollection
respectively (more on that below).
Resources, Collections, and Pages
In Membrane drivers, we organize our code around key resources.
The pattern is to create pairs of Collection and Resource objects in the
index.ts
file, where collections handle operations like listing and creation,
while resources handle item-specific operations.
This Resource-Collection-Page pattern in Membrane provides a consistent interface where:
- Collections handle
one()
for getting single resources,page()
for listing resources, and other collection-level operations likecreate()
orsearch()
- Resources implement
gref
for referencing and item-specific operations likeupdate()
anddelete()
- Collection operations go on collections (listing, creating)
- Resource-specific operations go on the resource object (updating, deleting)
Let’s look at this pattern using Resend’s Emails and Domains as examples.
Email Collection
Collections typically implement one()
for getting single resources and methods
for creating new resources:
// index.tsexport const EmailCollection = { // Get a single email by ID async one({ id }: { id: string }) { const data = await api("GET", `emails/${id}`); return { ...data }; },
// Send a new email async send(args: { from: string; to: string[]; subject: string; html?: string; text?: string; }) { const data = await api("POST", "emails", undefined, args); return { ...data }; }};
Email Resource
Resources handle operations specific to a single item and must implement a
gref
for referencing:
// index.tsexport const Email = { // Create a reference to this email gref: function (_, { obj }) { return root.emails.one({ id: obj.id }); },
// Update email (e.g., reschedule) async update(args: { scheduled_at: string }, { self }) { const { id } = self.$argsAt(root.emails.one); return api("PATCH", `emails/${id}`, undefined, args); },
// Cancel a scheduled email async cancel(_, { self }) { const { id } = self.$argsAt(root.emails.one); return api("POST", `emails/${id}/cancel`); }};
Domain Collection (Pagination Example)
For resources that support listing, collections should implement a page()
function for pagination:
// index.tsexport const DomainCollection = { // Get a single domain async one({ id }: { id: string }) { return await api("GET", `domains/${id}`); },
// List domains with pagination async page() { const result = await api("GET", "domains"); return { items: result.data }; },
// Create a new domain async create(args: { name: string; region?: string }) { return await api("POST", "domains", undefined, args); }};
Graph References (grefs)
In Membrane, grefs (graph references) create a way to reference and track
specific resources across your graph. Every resource should implement a gref
function that returns a unique, consistent path to that resource.
Think of a gref like a URL - it’s a way to point to a specific resource that can be stored, passed around, and used later. For example:
// In Email resourcegref: function (_, { obj }) { return root.emails.one({ id: obj.id });}
// This creates references like:// resend:emails.one(id:"12345")
Grefs serve several important purposes:
- Resource Identity: They provide a standardized way to reference specific resources
- Resource Lookups: Programs can use grefs to consistently find and access resources
- Cross-Program Communication: Programs can pass grefs to each other to reference the same resource
- Action Context: When performing actions on a resource, the gref provides
context about which resource to act on using
self.$argsAt()
Testing Your Driver
Create a separate tests.ts
file to organize your tests. Here’s an example from
the Resend driver:
// tests.ts// Tests should cover not just single actions, but flows that combine multiple actions// This test demonstrates sending an email then checking its delivery statusexport const emailTests = { async testEmailDelivered() { const { id } = await root.emails.send({ from: "onboarding@resend.dev", to: ["delivered@resend.dev"], subject: "Test delivered email", html: "<p>This is a test email.</p>", })
await sleep(2) // wait for delivery
const { last_event } = await root.emails .one({ id: String(id) }) .$query("{ last_event }")
if (last_event !== "delivered") { throw new Error("Expected email to deliver") } },}
export const domainTests = { async testDomainList() { const domains = await root.domains.page().items.$query("{ name }") if (!domains.some((d) => d.name === "membrane.io")) { throw new Error("Expected membrane.io in list of domains") } },}
These tests are then imported and exposed in your graph through the Root object
in index.ts
, making them invoke-able in the Membrane Navigator:
// index.tsimport { emailTests, domainTests } from "./tests"
export const Root = { // ... other fields // Expose tests in the graph tests: () => Tests,}
export const Tests = { ...emailTests, ...domainTests,}
A Note on Types in Membrane
Driver types are defined using the Schema Editor. There you’ll define:
- Fields on the Root type (like
domains: DomainCollection
) - Collection types with their operations (
one
,page
,create
) - Resource types with their fields
- Page types for pagination results
For example, the Resend driver’s types are defined in the Schema Editor to establish:
- Collection type
DomainCollection
with:one
: returns typeDomain
page
: returns typeDomainPage
create
: returns typeDomain
DomainPage
type with fielditems
of typeList<Domain>
- Resource type
Domain
with its fields
The Schema Editor generates your memconfig.json
based on these type
definitions.
3. Best Practices
Collection Methods
Always implement these core methods on collections:
export const SomeCollection = { // Get single resource - required for grefs async one({ id }: { id: string }) { return api("GET", `resource/${id}`); },
// List resources with pagination when supported async page({ cursor } = {}) { const data = await api("GET", "resources", { page: cursor }); return { items: data.items.map((item) => ({ ...item, ...Resource })), next: data.hasNextPage ? { cursor: data.nextPage } : null }; }};
Error Handling
Provide clear, actionable error messages:
// Configuration errorsif (!state.API_KEY) { throw new Error("API key not configured")}
// API errorsif (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`)}
Resource References
Every resource must implement gref
for consistent referencing:
export const Resource = { gref: function (_, { obj }) { return root.resources.one({ id: obj.id }) },}
Testing
- Write tests that combine multiple actions (like sending an email then checking its status)
- Test all exposed operations
Code Organization
- Keep collection operations on collections (listing, creating, searching)
- Keep resource operations on resources (updating, deleting)
- Use descriptive names for actions
- Consider adding JSDoc comments for complex operations
4. Publishing Your Driver
-
Include a README.md with:
- Configuration steps
- Basic usage examples
- Available methods
-
Test before publishing:
// Run all testsawait root.tests.testEmailDelivered()await root.tests.testDomainList() -
Share your driver in discord!
Remember: The best drivers make complex APIs feel simple and intuitive to use within the Membrane ecosystem.
As you build more drivers and connect more services, you’ll unlock increasingly powerful automation and integration possibilities.
5. Missing a Driver?
Missing a driver for one of your favorite APIs?
- Request it in our community, or let us build it for you - just reach out at contact@membrane.io.
- Contribute it!