Skip to content

Integrations ​

An integration is a connection to a service: a database, a cache, a message queue, a mail server. A plugin can add one, so the project can configure that connection once and then leave the credentials off every canvas.

A plugin's integration is written in src/integrations as a file that default-exports integration({...}):

ts
import { param } from "@fluxify/sdk/blocks";
import { integration } from "@fluxify/sdk/integrations";

export default integration({
	name: "acme/slack/hub",
	label: "Slack hub",
	icon: "./icons/hub.svg",
	group: "email",
	variant: "smtp",
	config: {
		host: param.string({ label: "Host", required: true }),
		password: param.secret({ label: "Password", required: true }),
	},
	async connect(config, ctx) {
		return new HubClient(config.host, ctx.getConfig(config.password));
	},
});

Its name is the plugin name plus its path under src/integrations, like a block's type. integration() checks the definition when the file loads and throws an error that names the integration, so a mistake appears in bun test and in the build.

Two kinds of integration ​

KindWhat it doesWho uses it
A variant of a built-in groupImplements the same contract as the built-in variant, so built-in blocks keep workingThe built-in blocks of that group, and your own blocks
A group of your ownA new kind of connection, with a contract you designOnly your plugin's blocks

The built-in groups are database, kv, queue, ai, observability, baas and mcp.

A variant of a built-in group shows up under that group with a Plugin badge, next to PostgreSQL or Redis. For example, a variant with group: "kv" becomes a choice for the built-in KV blocks without changing them.

A group of your own is filed under your plugin's scope: a plugin acme/slack with group: "email" becomes acme/email. Only blocks of the same plugin can use it. The group you write is a plain name; the scope is added for you, and integration() refuses a group that already has a slash.

INFO

A plugin can use the built-in groups and its own groups. Using another plugin's group needs a dependency between plugins, which is not supported yet.

integration({...}) field by field ​

FieldRequiredWhat it is
nameYesThe integration's type: the plugin name plus its path, scope/name/folder/integration
labelYesThe name shown on the integrations page
descriptionNoOne line under the label
iconYesAn image inside the plugin. The build copies it into icons/
docsNoMarkdown for the integration's page, in the same shape as a block's Docs tab
groupYesA built-in group name, or a plain name of your own
variantYesThis integration's name inside its group. It cannot contain /
configYesThe form the user fills in (below)
tabsNoThe order of the form's tabs; default is first use, with General first
poolKeyNoWhat decides which configs share a client
connectYesBuilds the client from the config
testNoTest connection
describeNoMetadata for pickers
inspectNoAnswers one request, such as a table's columns
inspectRequestNoA zod schema that checks an inspect request first
closeNoCloses a client
extrasDatabase onlyHow the variant behaves in the database blocks
adapterDatabase onlyBuilds one request's adapter on the pooled client

The variant's name and group become its kind, written group/variant (kv/Redis Cache, acme/email/smtp). A block's permission and its param.integration({ kind }) name that kind.

Config ​

config uses the same param.* builders as a block's params, with a smaller set of types:

TypeWhat the user sees
stringA one-line text box
numberA number box
booleanA checkbox
selectA dropdown, with options
jsonA JSON editor
secretA picker of the project's App config keys (below)
keyValueRows of a key and a value
arrayA list of items
hintA callout, with no value

label, description, required, default, visibleWhen, tab and order work as they do on a block. A hint takes text, tone, tab, order and visibleWhen.

An integration's config cannot use js, appconfig, conditions, schema or fieldMap: a config is a stored form, not something evaluated per request. Where a block reads an App config value through param.appconfig, an integration uses param.secret(). See Params for every option.

Secrets ​

param.secret() stores only the name of an App config key, never its value. The user picks the key in the integration form, and the stored config holds cfg:<KEY>.

Your hooks read the value with ctx.getConfig(key) in connect, or the second argument in test and inspect:

ts
config: { password: param.secret({ label: "Password", required: true }) },
async connect(config, ctx) {
	// config.password is the key the user picked, not the password
	const password = ctx.getConfig(config.password);
},
  • getConfig reads only the keys your own secret fields name. Asking for any other key throws an error that names the key.
  • Reading your own secret keys needs no appconfig permission. The approval covers the integration, not each key.
  • An error from getConfig names the missing key, never a value.
  • test, describe and inspect do not get getConfig. They receive the stored config and are run with the secret values already available to the connection, so connect is the place to read a secret.

connect ​

connect(config, ctx) builds the client. It may be async. The config arrives with defaults filled in and required params checked.

ctx has three members:

MemberWhat it is
ctx.getConfig(key)The value of an App config key one of your secret fields names (above)
ctx.loggerlogInfo, logWarn and logError
ctx.signalAborted when the client is closed, or when the Test connection that opened it is done. Pass it to a request library to cancel work

A variant of a built-in group must return that group's adapter type from @fluxify/sdk/integrations; leaving a method out is a type error. A group of your own may return anything your blocks need. Blocks get the type from typeof yourIntegration, so you do not write a second client type.

one client per config ​

The runtime opens one client per variant and per poolKey(config) and shares it. A later request with an equivalent config gets the same client instead of reconnecting.

The default key is the whole config, including the values of its secrets. Rotating a secret's value in App config therefore makes a new key and opens a new client.

Define poolKey when the config holds something that must not be part of the key, or when two configs should share a client:

ts
// the key is the App config key, not its value
poolKey: (config) => config.url,

Rotating a secret under a custom pool key keeps the old client

The config your poolKey sees holds the secret's key, not its value. If the key decides the pool key, changing the value in App config does not change the pool key, so the existing client stays in use until the config changes some other way, or the worker restarts. Rotate a secret used this way by editing the integration or restarting the worker.

close(client) is called when a client's pool slot goes idle and when the worker shuts down. Closing a client that was never opened is not an error.

Test connection, metadata and inspect ​

These three optional hooks let the project check an integration without running a route. Each one opens a fresh client with connect, runs the hook, and closes the client with close.

HookSignatureAnswer
testtest(client, config){ success: true }, or { success: false, error }. A throw also fails with the message. Returning nothing means success
describedescribe(client, config)Metadata for pickers and the AI agent
inspectinspect(client, request, config)The answer to one request

inspectRequest is a zod schema; the request is checked against it before your hook sees it. A bad request fails with the field name, without opening a client.

ts
import { z } from "zod";

inspectRequest: z.object({ table: z.string().min(1) }),
async inspect(client, { table }) {
	return client.describeTable(table);
},

For a database variant, describe answers { tables } and inspect answers the schema detail for the requested tables:

ts
describe: async (adapter) => ({ tables: await adapter.introspect!() }),
inspectRequest: z.object({ tables: z.array(z.string()).optional() }),
inspect: (adapter, { tables }) => describeSchema(adapter, tables),

These hooks run on a worker

Test connection, metadata and inspect run on a worker of the integration's own environment: a development config goes to a development worker, a production config to a production worker. The admin never runs your plugin code. If no worker of that environment is running, the person testing sees No development worker is running for this project. A hook has ten seconds of its own, and the admin waits fifteen seconds by default; a worker that misses that reports No response from a development worker in 15s.

The person testing can press Cancel. The test ends at once with Cancelled, and the process running your hook is stopped, so a hook that is waiting on a slow host needs no code of its own to support it.

close ​

close(client) releases what connect opened: a socket, a pool, a background listener. It runs when:

  • a pooled client sits idle past the pool's timeout
  • the worker shuts down
  • a Test connection, describe or inspect is done with its client

Use ctx.signal for work that should stop the moment the client closes.

Database variants ​

A database variant has the same two-step shape as the built-in database connections, because transactions need it.

  1. connect(config) opens the pool and returns an object with your pool as db and a close() that closes it.
  2. adapter(pool, config) is called once per request and returns a DatabaseAdapter built on that pool.
ts
integration({
	group: "database",
	variant: "My SQL",
	extras: { conditionEditor: "sql", dbType: "mysql" },
	async connect(config) {
		const pool = await openPool(config);
		return { db: pool, close: () => pool.end() };
	},
	adapter(pool, config) {
		return new MyAdapter(pool.db as Pool);
	},
});

Keep the transaction state on the adapter, not on the pool. A block inside a transaction starts it on its own request's adapter, and every later block in that request gets the same adapter, so they join the transaction. One adapter shared between requests would mix two requests' BEGINs: a rollback in one could roll back the other, and a commit would commit both. The runtime calls adapter per request precisely to avoid that.

extras is required for a database variant:

FieldWhat it is
conditionEditor"sql" for hand-written SQL, or "js" for a JavaScript expression that returns a native filter
dbTypeThe driver name the runtime resolves this variant to. It must be unique among the project's variants

The adapter must implement every method of DatabaseAdapter from @fluxify/sdk/integrations: getAll, getPage, getSingle, count, insert, insertBulk, update, raw, delete, setMode, startTransaction, commitTransaction and rollbackTransaction. introspect() is optional; a database that cannot describe itself may leave it out, and pickers then offer fewer choices.

AdapterReference
PostgreSQLpostgresAdapter.ts
MySQLmySqlAdapter.ts
MongoDBmongoDbAdapter.ts

KV variants ​

A kv variant returns a KvAdapter from @fluxify/sdk/integrations with four methods:

MethodWhat it does
get(key)The value, or null when the key is missing
set(key, value)Writes a value
setex(key, seconds, value)Writes a value that expires after seconds
delete(key)Removes a key

Values are text in both directions: a number, boolean, date or null is stored and read back as a string. A missing key must read as null, not "", not "null".

getConnection() is optional and lets an inspect hook reach the provider's own client. Return it if you implement inspect.

ts
async inspect(client, { key }) {
	const value = await client.get(key);
	return { key, found: value !== null, value };
},
AdapterReference
Redisredis.ts
Memcachedmemcached.ts

Queue variants ​

A queue variant returns a QueueProducer. It is the contract for sending messages, which the Send Message block uses. Consuming a queue is not part of it; queue triggers come later.

MemberWhat it does
send(messages)Publishes a list of OutgoingMessage and answers one SendOutcome per message, in order. A message the broker refuses is an outcome, not a throw. A throw means the broker could not be reached
client()The provider's own connected client, for raw use
close()Closes the producer

An OutgoingMessage is { destination, payload, key?, headers?, options? }. destination is the topic, queue URL, routing key or stream in the provider's own terms, and options holds provider-specific settings such as a partition or a delay. encodePayload and settleEach from @fluxify/sdk/integrations help with JSON payloads and per-message outcomes.

ProducerReference
Kafkakafka.ts
NATSnats.ts
SQSsqs.ts
RabbitMQrabbitmq.ts

AI variants ​

An ai variant returns an AiAdapter. Fluxify owns the connection (the provider, the key from App config, the base URL, pooling and tracing). The models it hands out are the AI SDK's own, so you call generateText, streamText, generateObject, tool() or an Agent on them yourself.

ts
interface AiAdapter {
	languageModel(modelId?: string): LanguageModelV4; // no id: the integration's default model
	embeddingModel?(modelId?: string): EmbeddingModelV4;
}
MethodWhat it returns
languageModel()The chat model the integration is set up with
languageModel("gpt-5-mini")Another model from the same provider and key
embeddingModel("text-embedding-3-small")An embedding model, where the provider has them

ai and @ai-sdk/provider are peer dependencies, like @fluxify/sdk and zod: the worker provides them, so your models and the worker's AI SDK are always the same version. A provider package (@ai-sdk/openai-compatible, @ai-sdk/openai, …) is an ordinary dependency.

A variant for a provider the built-ins lack wraps that provider's AI SDK package:

ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { param } from "@fluxify/sdk/blocks";
import { type AiAdapter, integration } from "@fluxify/sdk/integrations";

export default integration({
	name: "acme/llm/groq",
	label: "Groq",
	icon: "./icons/groq.svg",
	group: "ai",
	variant: "Groq",
	config: {
		model: param.string({ label: "Model", required: true }),
		key: param.secret({ label: "API key", required: true }),
	},
	connect(config, ctx): AiAdapter {
		const groq = createOpenAICompatible({
			name: "groq",
			baseURL: "https://api.groq.com/openai/v1",
			apiKey: ctx.getConfig(config.key),
		});
		return { languageModel: (id = config.model) => groq.languageModel(id) };
	},
});

Using a model from a block ​

A block runs the model with the AI SDK. Here the model may call one tool, which saves a value through another integration. stopWhen bounds the loop:

ts
import { block, param } from "@fluxify/sdk/blocks";
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod";

export default block({
	name: "acme/llm/remember",
	label: "Remember",
	permissions: { integrations: ["ai", "kv"] },
	params: {
		llm: param.integration({ kind: "ai", required: true }),
		store: param.integration({ kind: "kv", required: true }),
		prompt: param.string({ label: "Prompt", required: true }),
	},
	async run({ params, ctx }) {
		const ai = await ctx.integrations.get<"ai">(params.llm);
		const kv = await ctx.integrations.get<"kv">(params.store);
		const { text } = await generateText({
			model: ai.languageModel(),
			prompt: params.prompt,
			tools: {
				remember: tool({
					description: "Save a fact for later",
					inputSchema: z.object({ key: z.string(), value: z.string() }),
					execute: async ({ key, value }) => {
						await kv.set(key, value);
						return "saved";
					},
				}),
			},
			stopWhen: stepCountIs(5),
		});
		return { output: text };
	},
});

Every model call shows in the route's trace as a step under your block, named ai:<provider>/<model>, with its finish reason and token counts. Prompts and answers are not recorded.

Long agent loops

A sync route has a request timeout. Put a block that runs many model calls in an async or queued invocation, so the loop is not cut short.

Observability variants ​

An observability variant returns an ObservabilityAdapter with logInfo(value), logWarn(value) and logError(value). A log destination that cannot connect must not fail a request; Fluxify falls back to the console.

References: loki.ts and openTelemetryLogs.ts.

BaaS variants ​

A baas variant returns a BaasAdapter with client(), which resolves to the provider's client. Blocks that know that provider call what they need on it.

MCP variants ​

An mcp variant returns an McpAdapter with listTools(), callTool(name, args, signal?) and close(). It describes an MCP server the AI agent can use. The built-in variant is mcp/http; a plugin variant is for another transport. MCP integrations are handled by the project itself, not sent to a worker.

Scaffolding ​

sh
bunx fluxify-cli integrations create mail --group email

This writes src/integrations/mail.ts, a test and a placeholder icon. The integration's type is the plugin name plus the path: acme/slack/mail.

FlagMeaning
--group <name>A built-in group, or a group of your own. Left out, the file's name is the group
--forceNot used by this command

The database template writes the connect and adapter(pool, config) split, so a new database variant starts in the shape transactions need. The other templates stub every method of their group's contract. Replace the stubs and run bun test.

Using the integration from a block ​

A block needs the permission, a param.integration, and ctx.integrations.get:

ts
import mail from "../integrations/mail";

permissions: { integrations: ["acme/email"] },
params: {
	mail: param.integration({ kind: "acme/email", required: true }),
},
async run({ params, ctx }) {
	const client = await ctx.integrations.get<typeof mail>(params.mail);
	return { output: await client.send(message) };
},

get<typeof yourIntegration> gives the type your connect returned, so the block needs no hand-written client type. param.integration needs its kind in permissions.integrations, and the build refuses a block that has one without the other. Context covers the request-scoped client, transactions and the errors a user or an author will see.

Variant-specific integrations ​

A param.integration can name the variants it accepts:

ts
params: {
	store: param.integration({ kind: "kv", variants: ["Redis"], required: true }),
},

variants changes three things:

  • The picker offers only integrations of those variants. A Memcached integration is not in the list.
  • The server refuses a value of any other variant on save, on enable and on an upgrade, with a message that names the variants: integration "cache" is Memcached; this field takes only Redis.
  • ctx.integrations.connection(id) types the variant's own client for you.

The names are the variants Fluxify shows in its own pickers: Redis, Memcached, PostgreSQL, MySQL, MongoDB, Kafka, NATS, SQS, RabbitMQ, OpenAI, Anthropic, Gemini, Mistral, OpenAI Compatible, Open Telemetry, Loki, http. A plugin variant uses the variant its integration({...}) declares.

The build stops when:

ProblemThe error says
variants is an empty listvariants lists at least one variant; leave it out for every variant
The same name twicevariants must be unique
The kind already names a variant (kv/Redis)kind 'kv/Redis' names a variant already; write kind 'kv' to list variants
A name the kind's group does not have'Foo' is not a variant of kv; known: Redis, Memcached

The permission still names the group, not the variant: permissions: { integrations: ["kv"] }.

connection, not get ​

ctx.integrations.connection(id) is get with two extras: which variant the integration is, and the variant's own client.

ts
const connection = await ctx.integrations.connection(ctx.params.store);
connection.variant; // "Redis"
connection.client;  // the kv adapter: get, set, setex, delete
connection.raw;     // the ioredis Redis client
MemberWhat it is
variantThe variant's name, never an alias
clientWhat get would return: the group's adapter, or your own integration's client
rawThe variant's own client. undefined where it has none

The type follows the param. With one variant, raw is that client and needs no check. With several, raw is a union, and you narrow on variant before using it:

ts
params: {
	database: param.integration({
		kind: "database",
		variants: ["PostgreSQL", "MySQL"],
		required: true,
	}),
},
async run({ params, ctx }) {
	const connection = await ctx.integrations.connection(params.database);
	if (connection.variant === "PostgreSQL") {
		await connection.raw`SELECT 1`; // Bun's SQL
	} else {
		await connection.raw.query("SELECT 1"); // mysql2's pool
	}
},

Without variants, the type covers every built-in variant of the group plus a plugin variant whose raw is unknown, so it cannot be narrowed to one client. List the variants when you use raw.

For an integration your own plugin provides, connection<typeof myIntegration>(id) types raw as what its connect returned. connection has the same grants and errors as get.

What raw is, per variant ​

GroupVariantraw
kvRedisAn ioredis Redis
kvMemcachedThe memcached client
databasePostgreSQLBun's SQL
databaseMySQLA mysql2 Pool
databaseMongoDBA Db
queueKafkaA Producer
queueNATSA JetStreamClient
queueSQSAn SQS client
queueRabbitMQA ConfirmChannel
ai, observability, mcpevery variantundefined
any groupa plugin variantWhat its connect returned

The types of those drivers are optional peer dependencies. Installing one, for example bun add -d ioredis or bun add -d mysql2, gives you its real types and completion on raw. Without it, raw is unknown and the file still compiles. Your plugin does not need the driver at run time: the worker opens the integration's client and hands it over.

A database's raw client is outside the transaction

For a database variant, raw is the pool, not the request's transaction. A query sent on it runs in its own transaction, does not see a transaction another block started, and is not rolled back with it. Use client for the request's adapter, and reach for raw only when you need a query the adapter does not offer.

The Redis-streams case ​

A trigger that reads a Redis stream needs XREADGROUP and XACK, which the KV adapter does not have. Narrowing the param to Redis is what makes them available:

ts
permissions: { integrations: ["kv"] },
params: {
	store: param.integration({ kind: "kv", variants: ["Redis"], required: true }),
},
async start(ctx) {
	const connection = await ctx.integrations.connection(ctx.params.store);
	const redis = connection.raw; // ioredis, typed from the param
	// XREADGROUP, then ctx.emit, then XACK once the run resolved
},

Without variants, the same call could be handed a Memcached integration and would fail at run time.

When a saved integration no longer fits ​

A saved value of a variant the param no longer accepts is not cleared. The field shows an error that names the variants it takes, and the trigger or block is refused on save, enable or upgrade until it points at a fitting integration.

Narrowing variants in a new plugin version blocks the upgrade only while one of the project's saved values is of a dropped variant. Widening the list, or adding variants that every saved value already fits, blocks nothing. Upgrading lists the rest of the rules.

Limits ​

  • A plugin can add variants to the seven built-in groups, and groups of its own.
  • A block can be granted a built-in group and its own plugin's groups. Another plugin's group is refused until plugin-to-plugin dependencies exist.
  • A plugin's own group is visible only in projects that installed and approved that plugin, and only while the plugin is enabled in that environment.

A worked example ​

The integrations example has a KV variant on Redis, an email group with an smtp variant, and a block that sends through it:

FileWhat it shows
src/integrations/cache.tsA kv variant with a secret URL, a key prefix and a custom poolKey
src/integrations/smtp.tsA group of its own, a secret password read with getConfig, and Test connection
src/blocks/email/send.tspermissions.integrations, param.integration, and get<typeof smtp>

Next ​

  • Context: using an integration in a block
  • Testing: runIntegration and the runBlock integrations fake
  • Upgrading: which changes to an integration block an upgrade

Released under the Apache License 2.0. Enterprise features are under the Fluxify Enterprise Edition License.