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
statusandbodyon 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-referenceguide. It lists the endpoints the signed-in account can call, grouped by plugin, with acurlexample for a personal API token and the matchingElowenApicall, both built from the schemas. - Agents in a Chetty conversation use the
ElowenApitool'ssearchanddescribeactions, which read the same catalog, then call the endpoint with the defaultrequestaction. - Scripts and other clients send a personal API token and call the plugin route under
/apion the instance address; an MCP client useselowen_requestand the terminal appelowen 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
capabilities.readscontainscontrols, and the key is inconsumesControls(and inrequiresControlswhen the plugin cannot work without it).- The administrator approved
control:<key>. - Every use calls
ctx.control(key, methods)with the contract's descriptor and handlesundefined. - A route-backed member is called only from your own authenticated API route.
nullfrommailGraphForordriveGraphForbecomes a reconnect state; every rejection stays an error.- Your routes have precise summaries and described fields; check them on the API reference page.