MallaDocs

Write a plugin

From an empty file to a tested connector: operations, secrets, errors, tests and a run in the sandbox.

Developer guide
Intermediate45 min

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.

Write a pluginVideo coming soon. The written steps below cover everything.
Write a plugin· 12 min

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

  1. Create the file and the config schema

    Create packages/plugins/src/shop.ts. The plugin id is shop, so the file name follows the other plugins in the folder.

    ts
    import { 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 (baseUrl shows as "Base url"), and the .describe() text is the hint under it. Fields with a .default() are optional in the form. The schema allows http so 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.

  2. Describe the inputs

    Add the operation input schemas under the config. They validate the resolved input before run is called, and they become the input form of the query.

    ts
    const 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'),
    })
    
  3. Read the secret and call the API

    Add two helpers. ctx.secrets holds the decrypted secrets that have a value, so a missing key throws invalid_config. ctx.fetch is the policy-enforcing fetch. readBody and upstreamError from ./http parse the answer and turn a non-2xx status into an operation_failed error. joinUrl from ./rest keeps paths under the base URL.

    ts
    function 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
    }
    
  4. Define the plugin and its connection

    Now call definePlugin. Leave operations empty for the moment.

    ts
    export 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: {},
    })
    
    • secrets lists the secret fields. Values are encrypted at rest and never come back from the api. Field names must be letters and digits (apiKey, not api-key); definePlugin throws otherwise.
    • hosts is the outbound allowlist: ctx.fetch can only reach the hosts it returns for the saved config. There is no separate network permission for first-party plugins; permissions.network belongs to third-party catalog manifests.
    • secretBoundFields names 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.
    • test runs when someone presses Run test. Make a cheap call that needs the key, and throw a PluginError when it fails. A plain Error is not mapped and the test answers a server error instead of a readable message.
    • icon is a Lucide icon name. The editor only bundles icon names that appear in its own sources, so pick one it already uses (lucide:package is one) or the icon shows blank.
    • firstParty: true gives the context the first-party capabilities described below.

Add the operations

  1. Add the query

    Replace operations: {} with the operations map. Operation ids must be <plugin id>.<name> in camelCase; definePlugin checks it when the module loads.

    ts
      operations: {
        '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
          },
        }),
      },
    

    defineOperation types input from the schema, so input.search is a string | undefined. Annotate ctx with your config type to read ctx.config.baseUrl with its type. readPath reads a dot path (items, data.items.0.name) through own properties only and returns undefined for a missing step. The output schema is not enforced; the editor uses it for autocomplete.

  2. Add the mutation

    Add the second entry to the same map. A mutation changes 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.quantity is a number here, not optional: the schema default fills it in before run.

Checkpoint

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.

CodeWho raises it
invalid_configYour code, for a missing secret or a bad setting. Also the SDK, when the saved config does not match the schema.
invalid_inputThe SDK, when the input does not match the operation schema. Your code, for checks the schema cannot express.
operation_failedYour code (upstreamError), for a failed upstream call. Also ctx.fetch, for network errors and too many redirects.
fetch_blockedctx.fetch: a host not in hosts, plain HTTP, a private address, or a redirect to another host.
dns_failedctx.fetch: the host could not be resolved.
timeoutctx.fetch: no answer within the time limit.
response_too_largectx.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

  1. Add it to the registry

    Open packages/plugins/src/index.ts. Import the plugin, export it with its config type, and add it to PLUGINS. The order of PLUGINS is the order of the editor menu.

    ts
    import { shopPlugin } from './shop'
    
    export { shopPlugin, type ShopConfig } from './shop'
    
    export const PLUGINS: readonly Plugin[] = [
      restPlugin,
      // …the other plugins
      openaiPlugin,
      shopPlugin,
    ] as readonly Plugin[]
    
  2. Reserve the id

    Add 'shop' at the end of BUILTIN_PLUGIN_IDS in packages/schema/src/data.ts. The list keeps catalog plugins from taking a built-in id, and plugins.test.ts checks that both lists match.

  3. 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 /plugins test in apps/api/src/routes/integrations.test.ts.
  4. Update the editor and the docs

    If your plugin has secretBoundFields, add it to SECRET_BOUND_FIELDS in apps/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

  1. Create the test file

    Create packages/plugins/src/shop.test.ts. The helpers in test-utils.ts build 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 a PluginError; json() builds a JSON response.

    ts
    import { 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']!
    
  2. 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.

    ts
    describe('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' })
      })
    
  3. Test the failures and the connection test

    Cover each error your code raises, the policy (a host outside hosts is blocked), and both outcomes of the connection test. Close the describe block at the end.

    ts
      it('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' })
      })
    })
    
  4. Run them

    Run the plugin tests from the repository root:

    sh
    pnpm --filter @malla/plugins test
    
Checkpoint

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

  1. Start a local Shop API

    The e2e fake API answers GET /products?search= behind an X-Api-Key header, which is enough for the query and the connection test. Start it in its own terminal with a port and a key:

    sh
    node apps/web/e2e/support/fake-api.mjs 4199 shop-demo-key
    

    It has no POST /orders, so try the mutation in your unit tests, or against a real API.

  2. Start the sandbox

    In another terminal, run pnpm sandbox and log in at http://localhost:3200/login with the demo account from scripts/sandbox/README.md. The sandbox sets ALLOW_LOCAL_INTEGRATIONS=1, so the api may call http://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.

  3. 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, enter shop-demo-key as the API key in Secrets, then press Create integration.

  4. 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.

  5. Run a query

    Next to Queries, press + (Add query). In Source, set Integration to your Shop API integration and Operation to List products. Type lamp in the Search field under Input, then press Run in the Result card.

Checkpoint

The Result card shows two products, Alpine Lamp and Dune Lamp, from your plugin.

Run the quality gates

  1. Type check and test the monorepo

    From the repository root, run the quality gates from the Local development guide:

    sh
    pnpm check-types
    pnpm test
    

    pnpm test includes the GET /plugins test in api and the registry test in @malla/plugins. To rerun only the api test, use pnpm --filter api exec vitest run src/routes/integrations.test.ts.

  2. 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.

Done

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 definePlugin call: a connection (Zod config, secret fields, hosts, test) and operations built with defineOperation.
  • .describe() on the schemas writes the hints of the integration and query forms.
  • ctx.fetch only reaches the hosts the connection returns, and it raises PluginError codes for blocked, slow or oversized calls.
  • A new plugin also goes into PLUGINS, BUILTIN_PLUGIN_IDS and the two tests that list every plugin.
  • test-utils.ts runs your operations behind the real fetch policy with a fake upstream.

Read the guides behind this tutorial: