You will build: A tested Shop API connector with an API key, a product query and an order mutation, running in the local sandbox
Read first: Local developmentQueries and integrations
In this tutorial you write a connector for a small JSON API, from an empty file to a passing test suite and a query that runs in the editor. The example is a Shop API with an API key, one query (list products) and one mutation (create an order). You need the repository checked out and installed, and you should know how queries and integrations work in the editor.
Plan the connector
A plugin has two parts. The connection describes one integration: its settings (a Zod schema), its secret fields, the hosts it may reach and a test. The operations are what queries and workflows call: each one has a kind (query or mutation), a Zod input schema and a run function. The Plugin SDK guide has the full reference; this tutorial builds one.
The Shop API in this example answers GET /products?search= with { "items": [...] } and POST /orders with { "id", "status" }. It wants the key in an X-Api-Key header.
Write the connection
Create the file and the config schema
Create
packages/plugins/src/shop.ts. The plugin id isshop, so the file name follows the other plugins in the folder.tsimport { defineOperation, definePlugin, PluginError, readPath, type PluginContext } from '@malla/plugin-sdk' import { z } from 'zod' import { readBody, upstreamError } from './http' import { joinUrl } from './rest' // Shop API connector: search the product catalog and place orders. export const shopConfigSchema = z.object({ baseUrl: z.url({ protocol: /^https?$/ }).describe('API base URL, e.g. https://api.shop.example/v1'), currency: z.enum(['USD', 'EUR', 'MXN']).default('USD').describe('Currency for new orders'), }) export type ShopConfig = z.output<typeof shopConfigSchema>The config schema becomes the form in the editor. Each key is a field, its label is the key split into words (
baseUrlshows as "Base url"), and the.describe()text is the hint under it. Fields with a.default()are optional in the form. The schema allowshttpso the sandbox can reach a local API; the outbound policy still refuses plain HTTP to anything but a local host, and only when local integrations are allowed.Describe the inputs
Add the operation input schemas under the config. They validate the resolved input before
runis called, and they become the input form of the query.tsconst product = z.object({ id: z.string(), name: z.string(), price: z.number() }) export const listProductsInput = z.object({ search: z.string().trim().max(200).optional().describe('Only products whose name contains this text'), }) export const createOrderInput = z.object({ productId: z.string().min(1).max(64).describe('Id of the product to order'), quantity: z.number().int().min(1).max(100).default(1), email: z.email().describe('Where the shop sends the receipt'), })Read the secret and call the API
Add two helpers.
ctx.secretsholds the decrypted secrets that have a value, so a missing key throwsinvalid_config.ctx.fetchis the policy-enforcing fetch.readBodyandupstreamErrorfrom./httpparse the answer and turn a non-2xx status into anoperation_failederror.joinUrlfrom./restkeeps paths under the base URL.tsfunction apiKey(ctx: PluginContext<ShopConfig>): string { const value = ctx.secrets.apiKey if (!value) throw new PluginError('invalid_config', 'Secret apiKey is not set', { issues: [{ path: 'secrets.apiKey', message: 'Required' }] }) return value } /** GET or POST to the Shop API with the key header; JSON body in, parsed body out. */ async function call(ctx: PluginContext<ShopConfig>, path: string, body?: unknown): Promise<unknown> { const url = joinUrl(ctx.config.baseUrl, path) const headers = new Headers({ accept: 'application/json', 'x-api-key': apiKey(ctx) }) if (body !== undefined) headers.set('content-type', 'application/json') const response = await ctx.fetch(url, { method: body === undefined ? 'GET' : 'POST', headers, body: body === undefined ? null : JSON.stringify(body), }) const result = await readBody(response) if (!response.ok) throw upstreamError(response, result) return result }Define the plugin and its connection
Now call
definePlugin. Leaveoperationsempty for the moment.tsexport const shopPlugin = definePlugin({ id: 'shop', name: 'Shop API', version: '1.0.0', description: 'Search the product catalog and place orders in your Shop API.', firstParty: true, icon: 'lucide:package', connection: { config: shopConfigSchema, secrets: { apiKey: { label: 'API key', description: 'Sent as the X-Api-Key header' } }, secretBoundFields: ['baseUrl'], hosts: (config) => [new URL(config.baseUrl).host], async test(ctx) { await call(ctx, '/products?search=') }, }, operations: {}, })secretslists the secret fields. Values are encrypted at rest and never come back from the api. Field names must be letters and digits (apiKey, notapi-key);definePluginthrows otherwise.hostsis the outbound allowlist:ctx.fetchcan only reach the hosts it returns for the saved config. There is no separate network permission for first-party plugins;permissions.networkbelongs to third-party catalog manifests.secretBoundFieldsnames the settings that decide where the key goes. Changing one, or the host set, clears the stored secrets unless the same update sends new values.testruns when someone presses Run test. Make a cheap call that needs the key, and throw aPluginErrorwhen it fails. A plainErroris not mapped and the test answers a server error instead of a readable message.iconis a Lucide icon name. The editor only bundles icon names that appear in its own sources, so pick one it already uses (lucide:packageis one) or the icon shows blank.firstParty: truegives the context the first-party capabilities described below.
Add the operations
Add the query
Replace
operations: {}with the operations map. Operation ids must be<plugin id>.<name>in camelCase;definePluginchecks it when the module loads.tsoperations: { 'shop.listProducts': defineOperation({ kind: 'query', name: 'List products', description: 'Products from the catalog, optionally filtered by name.', input: listProductsInput, output: z.array(product), async run(ctx: PluginContext<ShopConfig>, input) { const query = new URLSearchParams(input.search ? { search: input.search } : {}) const body = await call(ctx, `/products?${query}`) const items = readPath(body, 'items') if (!Array.isArray(items)) throw new PluginError('operation_failed', 'The Shop API answered without an items list') return items }, }), },defineOperationtypesinputfrom the schema, soinput.searchis astring | undefined. Annotatectxwith your config type to readctx.config.baseUrlwith its type.readPathreads a dot path (items,data.items.0.name) through own properties only and returnsundefinedfor a missing step. Theoutputschema is not enforced; the editor uses it for autocomplete.Add the mutation
Add the second entry to the same map. A
mutationchanges data upstream, so the editor lists it with "(mutation)" after its name.ts'shop.createOrder': defineOperation({ kind: 'mutation', name: 'Create order', description: 'Places an order and returns its id and status.', input: createOrderInput, output: z.object({ id: z.string(), status: z.string() }), async run(ctx: PluginContext<ShopConfig>, input) { const order = await call(ctx, '/orders', { product_id: input.productId, quantity: input.quantity, email: input.email, currency: ctx.config.currency, }) return { id: readPath(order, 'id') ?? null, status: readPath(order, 'status') ?? 'unknown' } }, }),input.quantityis anumberhere, not optional: the schema default fills it in beforerun.
pnpm --filter @malla/plugins check-types passes with the new file.
Errors and other capabilities
Throw PluginError from @malla/plugin-sdk with one of its codes. The api maps the code to an HTTP status and an error code, and the Test connection banner shows the message with the code in brackets.
| Code | Who raises it |
|---|---|
invalid_config | Your code, for a missing secret or a bad setting. Also the SDK, when the saved config does not match the schema. |
invalid_input | The SDK, when the input does not match the operation schema. Your code, for checks the schema cannot express. |
operation_failed | Your code (upstreamError), for a failed upstream call. Also ctx.fetch, for network errors and too many redirects. |
fetch_blocked | ctx.fetch: a host not in hosts, plain HTTP, a private address, or a redirect to another host. |
dns_failed | ctx.fetch: the host could not be resolved. |
timeout | ctx.fetch: no answer within the time limit. |
response_too_large | ctx.fetch: the body is larger than 2 MB. |
Besides config, secrets and fetch, the context has ctx.signal (aborts when the caller cancels) and ctx.published (true on the live site, false in the editor). First-party plugins also get ctx.cms, to list and get CMS items, and ctx.postgres, for Postgres over TCP with the same address checks as ctx.fetch (see cms.ts and postgres.ts). In the editor only, ctx.saveConfig stores a new config for the integration.
Register the plugin
Add it to the registry
Open
packages/plugins/src/index.ts. Import the plugin, export it with its config type, and add it toPLUGINS. The order ofPLUGINSis the order of the editor menu.tsimport { shopPlugin } from './shop' export { shopPlugin, type ShopConfig } from './shop' export const PLUGINS: readonly Plugin[] = [ restPlugin, // …the other plugins openaiPlugin, shopPlugin, ] as readonly Plugin[]Reserve the id
Add
'shop'at the end ofBUILTIN_PLUGIN_IDSinpackages/schema/src/data.ts. The list keeps catalog plugins from taking a built-in id, andplugins.test.tschecks that both lists match.Update the tests that list every plugin
Two tests list the plugin ids in order. Add
'shop'at the end of both:- the first test in
packages/plugins/src/plugins.test.ts("bundles every first-party plugin, each reserved as a built-in id"), - the
GET /pluginstest inapps/api/src/routes/integrations.test.ts.
- the first test in
Update the editor and the docs
If your plugin has
secretBoundFields, add it toSECRET_BOUND_FIELDSinapps/web/app/editor/logic/integrationSecrets.ts, with the field that holds the address and the other bound fields. The editor uses that map to warn before a save clears the secrets; the api clears them either way.Then add a row for the plugin to the "Other built-in connectors" table in
docs/architecture/05-bindings-data.md.
Write the tests
Create the test file
Create
packages/plugins/src/shop.test.ts. The helpers intest-utils.tsbuild a context like the api does:context()parses the config, puts the real fetch policy in front of a fake upstream and records every request;failure()returns the code and details of aPluginError;json()builds a JSON response.tsimport { runOperation, type PluginContext } from '@malla/plugin-sdk' import { describe, expect, it } from 'vitest' import { shopPlugin, type ShopConfig } from './shop' import { context, failure, json } from './test-utils' const config = { baseUrl: 'https://api.shop.test/v1' } const secrets = { apiKey: 'sk_test' } const list = shopPlugin.operations['shop.listProducts']! const order = shopPlugin.operations['shop.createOrder']!Test the happy paths
Run operations through
runOperation, as the api does, so the input schema is applied first. Check the request the plugin sent as well as the result.tsdescribe('shop plugin', () => { it('lists products with the key header and unwraps items', async () => { const items = [{ id: 'p1', name: 'Alpine Lamp', price: 49.5 }] const { ctx, requests } = context(shopPlugin, config, () => json({ items }), { secrets }) expect(await runOperation(list, ctx, { search: 'lamp' })).toEqual(items) expect(requests[0]!.url).toBe('https://api.shop.test/v1/products?search=lamp') expect(requests[0]!.headers.get('x-api-key')).toBe('sk_test') }) it('creates an order in the configured currency', async () => { const { ctx, requests } = context(shopPlugin, { ...config, currency: 'EUR' }, () => json({ id: 'o_1', status: 'pending' }, 201), { secrets }) const result = await runOperation(order, ctx, { productId: 'p1', email: 'ada@example.com' }) expect(result).toEqual({ id: 'o_1', status: 'pending' }) expect(requests[0]!.method).toBe('POST') expect(await requests[0]!.json()).toEqual({ product_id: 'p1', quantity: 1, email: 'ada@example.com', currency: 'EUR' }) })Test the failures and the connection test
Cover each error your code raises, the policy (a host outside
hostsis blocked), and both outcomes of the connection test. Close thedescribeblock at the end.tsit('checks input, config and secrets', async () => { const { ctx } = context(shopPlugin, config, () => json({}), { secrets }) expect((await failure(runOperation(order, ctx, { productId: 'p1', email: 'nope' }))).code).toBe('invalid_input') const noKey = context(shopPlugin, config, () => json({ items: [] })) expect((await failure(runOperation(list, noKey.ctx, {}))).code).toBe('invalid_config') expect(() => context(shopPlugin, { baseUrl: 'ftp://shop.test' }, () => json({}))).toThrow() }) it('reports upstream errors and blocks other hosts', async () => { const { ctx } = context(shopPlugin, config, () => json({ message: 'Out of stock' }, 409), { secrets }) expect(await failure(runOperation(order, ctx, { productId: 'p1', email: 'ada@example.com' }))).toMatchObject({ code: 'operation_failed', details: { status: 409 } }) expect((await failure(ctx.fetch('https://evil.test/steal'))).code).toBe('fetch_blocked') }) it('tests the connection with an authenticated call', async () => { const ok = context(shopPlugin, config, () => json({ items: [] }), { secrets }) await expect(shopPlugin.connection!.test!(ok.ctx as PluginContext<ShopConfig>)).resolves.toBeUndefined() const denied = context(shopPlugin, config, () => json({ message: 'Invalid key' }, 401), { secrets }) await expect(shopPlugin.connection!.test!(denied.ctx as PluginContext<ShopConfig>)).rejects.toMatchObject({ code: 'operation_failed' }) }) })Run them
Run the plugin tests from the repository root:
shpnpm --filter @malla/plugins test
Every test in @malla/plugins passes, including the five in shop.test.ts and the registry test in plugins.test.ts.
Try it in the sandbox
Start a local Shop API
The e2e fake API answers
GET /products?search=behind anX-Api-Keyheader, which is enough for the query and the connection test. Start it in its own terminal with a port and a key:shnode apps/web/e2e/support/fake-api.mjs 4199 shop-demo-keyIt has no
POST /orders, so try the mutation in your unit tests, or against a real API.Start the sandbox
In another terminal, run
pnpm sandboxand log in athttp://localhost:3200/loginwith the demo account fromscripts/sandbox/README.md. The sandbox setsALLOW_LOCAL_INTEGRATIONS=1, so the api may callhttp://localhost. If the sandbox was already running, wrangler reloads the api when the plugin files change; if Shop API is missing from the menu in the next step, stop the sandbox with ⌃C and start it again.Create the integration
Open Demo site and pick Data in the left sidebar. Next to Integrations, press + (Add integration) and choose Shop API. Name it, set Base url to
http://localhost:4199, keep Currency, entershop-demo-keyas the API key in Secrets, then press Create integration.Test the connection
In the Test connection card, press Run test. A green banner says Connected. Change the key to a wrong value, press Save changes and run the test again: the banner shows
Upstream answered 401 (operation_failed). Put the right key back.Run a query
Next to Queries, press + (Add query). In Source, set Integration to your Shop API integration and Operation to List products. Type
lampin the Search field under Input, then press Run in the Result card.
The Result card shows two products, Alpine Lamp and Dune Lamp, from your plugin.
Run the quality gates
Type check and test the monorepo
From the repository root, run the quality gates from the Local development guide:
shpnpm check-types pnpm testpnpm testincludes theGET /pluginstest inapiand the registry test in@malla/plugins. To rerun only the api test, usepnpm --filter api exec vitest run src/routes/integrations.test.ts.Commit in small steps
Commit the plugin and its tests, then the registration (registry, reserved id, list tests), then the docs. Follow the micro-commit rules in
docs/frontend-conventions.md.
The Shop API connector is in the plugin registry, its tests pass, and a query using it runs in the sandbox editor.
What you learned
- A connector is a
definePlugincall: a connection (Zod config, secret fields, hosts, test) and operations built withdefineOperation. .describe()on the schemas writes the hints of the integration and query forms.ctx.fetchonly reaches the hosts the connection returns, and it raisesPluginErrorcodes for blocked, slow or oversized calls.- A new plugin also goes into
PLUGINS,BUILTIN_PLUGIN_IDSand the two tests that list every plugin. test-utils.tsruns your operations behind the real fetch policy with a fake upstream.
Read the guides behind this tutorial: