Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 27. októbra 2026

Prechádzať dokumentáciu · Connecting plugins

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Developer reference

Connecting plugins

This guide shows, step by step, how one plugin uses a capability another plugin already provides, and how clients outside Chetty reach any plugin's endpoints. The reference for every rule used here is in Controls and plugin dependencies and HTTP routes; this guide does not repeat it.

When to connect plugins

Connect to a peer when it already owns the thing you need: a signed-in account, a provider client, a sandbox. The peer publishes a control, a live domain interface under a stable key such as microsoftIdentity, and your plugin consumes that key. You never import the peer's code, read its storage or copy its credentials, and your plugin keeps working when the peer is updated or replaced by another provider of the same key.

The real example in this guide is the Microsoft 365 connection. The Microsoft Teams plugin signs the user in to Microsoft and provides microsoftIdentity; the registry plugins Email (plugins/email/src/index.ts, graphBackend.ts, api.ts) and OneDrive (plugins/onedrive/src/api.ts) consume it. The typed contract is MicrosoftIdentityControl in elowen-plugin-shared/microsoftIdentity.

1. Declare the controls you consume

Add the key to consumesControls and grant your plugin control reads. Add it to requiresControls too when the plugin cannot do its job without the provider; enabling it then fails with a dependency error until a provider is enabled. Email lists microsoftIdentity only in consumesControls, because it also serves IMAP and Google mailboxes; OneDrive lists it in both.

{
  "requiresSharedApi": 16,
  "capabilities": { "reads": ["controls"] },
  "consumesControls": ["microsoftIdentity"],
  "requiresControls": ["microsoftIdentity"]
}

2. Get the administrator's approval

A declaration does not authorize itself. Every consumed key needs the stored grant control:<key>, here control:microsoftIdentity. The administrator approves it in the consent step of install, enable or update; an API client replays the same request with the returned acknowledgeGrants. A new key in an update asks again. Without the approval ctx.control() returns undefined and logs a warning, exactly as when the provider is missing.

3. Resolve the control on every use

Call ctx.control(key, methods) where you need it, with the complete method descriptor the contract exports. The host checks that the provider has every method before handing it out; an incomplete, disabled or missing provider gives undefined. Treat that as a real state and answer honestly, never with an empty success. Do not keep the returned object: each call returns a fresh wrapper bound to the live provider.

import type { PluginContext } from 'elowen/plugin-api';
import { microsoftIdentityMethods, type MicrosoftIdentityControl } from 'elowen-plugin-shared/microsoftIdentity';

export async function register(ctx: PluginContext): Promise<void> {
  // The same resolver Email and OneDrive use.
  const identity = () => ctx.control<MicrosoftIdentityControl>('microsoftIdentity', microsoftIdentityMethods);
  // … routes and tools call identity() each time they run.
}

Worked example: mail through Microsoft Teams

A small "mail digest" plugin lists the user's Microsoft mailboxes, reads the newest messages of one and sends a plain-text message, all through the Teams connection. It owns no OAuth client and stores no token.

Declare the routes

Routes go in the pure src/apiContract.ts, as HTTP routes describes. The summaries and the zod schemas, including .describe() texts, are what the live API reference shows for these endpoints; nothing else needs writing.

import { z } from 'zod';
import type { PluginApiRouteContract } from 'elowen/plugin-api';

export const inboxQuerySchema = z.strictObject({
  ref: z.string().min(1).max(512).describe('Mailbox reference returned by the mailboxes route.'),
});
export const sendSchema = z.strictObject({
  ref: z.string().min(1).max(512).describe('Mailbox reference returned by the mailboxes route.'),
  to: z.email(),
  subject: z.string().max(255),
  text: z.string().min(1).max(100_000),
});

export const apiRoutes = {
  mailboxes: {
    method: 'GET', path: 'mailboxes', access: 'user',
    summary: 'List the Microsoft mailboxes the current account signed in to through Microsoft Teams.',
    input: { body: null, query: null, params: null },
    credentials: { agent: 'allow', apiToken: 'allow' }, response: 'json',
  },
  inbox: {
    method: 'GET', path: 'inbox', access: 'user',
    summary: 'Read the five newest messages in the inbox of one signed-in Microsoft mailbox.',
    input: { body: null, query: inboxQuerySchema, params: null },
    credentials: { agent: 'allow', apiToken: 'allow' }, response: 'json',
  },
  send: {
    method: 'POST', path: 'send', access: 'user',
    summary: 'Send a plain-text message from one signed-in Microsoft mailbox.',
    input: { body: sendSchema, query: null, params: null },
    credentials: { agent: 'deny', apiToken: 'allow' }, response: 'json',
  },
} satisfies Record<string, PluginApiRouteContract>;

Generate the manifest security declarations from this module as generated API security explains.

List the user's mailboxes

mailAccounts() answers the local { ref, address, displayName } records of the account behind the current authenticated request, without contacting Microsoft. Teams serves it as a route-backed member, so call it inside your own API route handler (Email's mailbox list, import and reconnect do the same).

ctx.registerApiRoute({ ...apiRoutes.mailboxes, handler: async () => {
  const control = identity();
  if (!control) return { status: 503, body: { error: 'microsoft_identity_unavailable' } };
  return { body: { mailboxes: await control.mailAccounts() } };
} });

Read and send mail

mailGraphFor(userId, ref) returns a token-free Graph client confined to the provider's mail allowlist, for that account's own mailbox reference. Its json, request and binary methods throw on a refused or failed call; let that error reach your handler's error mapping rather than turning it into an empty result.

import { z } from 'zod';

const messages = z.object({
  value: z.array(z.object({ subject: z.string(), receivedDateTime: z.string() })),
});

ctx.registerApiRoute({ ...apiRoutes.inbox, handler: async (req) => {
  const userId = req.auth.userId;
  if (userId === null) return { status: 401, body: { error: 'account_required' } };
  const { ref } = req.parseQuery(inboxQuerySchema);
  const control = identity();
  if (!control) return { status: 503, body: { error: 'microsoft_identity_unavailable' } };
  const graph = await control.mailGraphFor(userId, ref);
  if (graph === null) return reconnect(control, userId);
  const page = messages.parse(await graph.json('GET', '/me/mailFolders/inbox/messages?$top=5&$select=subject,receivedDateTime'));
  return { body: { messages: page.value } };
} });

ctx.registerApiRoute({ ...apiRoutes.send, handler: async (req) => {
  const userId = req.auth.userId;
  if (userId === null) return { status: 401, body: { error: 'account_required' } };
  // The declared policy refuses the agent credential; the handler applies it at its own gate.
  if (!req.credentialsAllowed()) return { status: 403, body: { error: 'human_required' } };
  const input = await req.json(sendSchema);
  const control = identity();
  if (!control) return { status: 503, body: { error: 'microsoft_identity_unavailable' } };
  const graph = await control.mailGraphFor(userId, input.ref);
  if (graph === null) return reconnect(control, userId);
  await graph.request('POST', '/me/sendMail', { body: {
    message: {
      subject: input.subject,
      body: { contentType: 'text', content: input.text },
      toRecipients: [{ emailAddress: { address: input.to } }],
    },
    saveToSentItems: true,
  } });
  return { status: 202, body: { sent: true } };
} });

mailGraphFor takes the account explicitly, so a tool or a scheduled job can use it as well: read the account with await ctx.currentAccountUserId() and keep the mailbox ref you received earlier, as Email stores it with each imported mailbox. Only mailAccounts() needs an HTTP request; see the next section.

Null means reconnect

mailGraphFor returns null only when that mailbox reference or its sign-in is unavailable. Answer with a reconnect state and the owner-bound sign-in address from mailSignIn(userId, returnTo); the user signs in again and returns to returnTo. Every other failure rejects: a provider, verification or transport error is an error, not a disconnected mailbox. Email draws the same line (GraphSignInRequiredError in plugins/email/src/graphBackend.ts).

import type { MicrosoftIdentityControl } from 'elowen-plugin-shared/microsoftIdentity';

async function reconnect(control: MicrosoftIdentityControl, userId: number) {
  return { status: 409, body: { error: 'reconnect_required', signIn: await control.mailSignIn(userId, '/p/mail-digest') } };
}

For files, OneDrive uses driveGraphFor(userId) the same way: null is a missing link or sign-in, and the client is confined to the user's drive paths.

Route-backed members and their limits

A provider can serve one member of its control through one of its own API routes by declaring control: { key, method } on that route; Teams does this for mailAccounts. To the consumer it is an ordinary method of the same control, under the same control:<key> approval.

Use one when the answer belongs to the account of the request in progress. Today such a member:

  • accepts no arguments, because the route declares no body, query or path parameters;
  • works only inside an authenticated HTTP dispatch of the consuming plugin, and acts as the original caller with its credential and Project limits, so it never takes an account, a role or credentials;
  • is not available to a tool turn, a sub-agent runner, a background service or an interval, which have no HTTP request to act for;
  • returns the route's JSON body, and rejects with the route's real status and body on a non-2xx answer.

When your feature runs outside an HTTP request, use a native member that takes the account explicitly, such as mailGraphFor(userId, ref). The exact rules are in Controls and plugin dependencies.

Call plugin endpoints from outside

Every authenticated route a plugin registers joins the live API catalog as soon as the plugin is enabled, with its summary, input schemas, access level, credential policy and response kind. Nobody writes it down:

  • People open Guides → Skills, MCP and API → API reference, the api-reference guide. It lists the endpoints the signed-in account can call, grouped by plugin, with a curl example for a personal API token and the matching ElowenApi call, both built from the schemas.
  • Agents in a Chetty conversation use the ElowenApi tool's search and describe actions, which read the same catalog, then call the endpoint with the default request action.
  • Scripts and other clients send a personal API token and call the plugin route under /api on the instance address; an MCP client uses elowen_request and the terminal app elowen api.

To make an endpoint easy to use from outside, give it a precise summary and describe its fields in the zod schema. Those texts appear in the reference and in the agent's search unchanged.

Checklist

  1. capabilities.reads contains controls, and the key is in consumesControls (and in requiresControls when the plugin cannot work without it).
  2. The administrator approved control:<key>.
  3. Every use calls ctx.control(key, methods) with the contract's descriptor and handles undefined.
  4. A route-backed member is called only from your own authenticated API route.
  5. null from mailGraphFor or driveGraphFor becomes a reconnect state; every rejection stays an error.
  6. Your routes have precise summaries and described fields; check them on the API reference page.